Method Channels in Flutter: solved exercise with native Android and iOS code

Method Channels in Flutter: solved exercise with native code

Platform channels are Flutter’s mechanism for calling native Android (Kotlin/Java) and iOS (Swift/Objective-C) APIs that have no pub.dev plugin, or when you need low-level platform access. MethodChannel is the most common channel: it enables synchronous request-response calls from Dart to native code.

Problem statement

Implement a screen that reads the device battery level using native code:

  • Define the MethodChannel with a unique name in Dart.
  • Implement the handler in Kotlin (MainActivity.kt) for Android.
  • Implement the handler in Swift (AppDelegate.swift) for iOS.
  • Display the percentage with an icon that changes based on the level.
  • Handle PlatformException when the battery is unavailable.

Dependencies

Only the Flutter SDK — MethodChannel is in package:flutter/services.dart.

1
2
3
dependencies:
  flutter:
    sdk: flutter

Full solution

Flutter (Dart)

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';

// ── Platform channel ──────────────────────────────────────────────────────────
// Name must be unique — use the bundle/package ID as prefix by convention
const _channel = MethodChannel('com.example.app/battery');

Future<void> main() => runApp(const MyApp());

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Method Channel Demo',
      theme: ThemeData(useMaterial3: true, colorSchemeSeed: Colors.orange),
      home: const BatteryPage(),
    );
  }
}

class BatteryPage extends StatefulWidget {
  const BatteryPage({super.key});

  @override
  State<BatteryPage> createState() => _BatteryPageState();
}

class _BatteryPageState extends State<BatteryPage> {
  int? _batteryLevel;
  String? _error;
  bool _loading = false;

  Future<void> _getBatteryLevel() async {
    setState(() {
      _loading = true;
      _error = null;
    });
    try {
      // invokeMethod throws PlatformException if native calls result.error()
      final level = await _channel.invokeMethod<int>('getBatteryLevel');
      if (mounted) setState(() => _batteryLevel = level);
    } on PlatformException catch (e) {
      if (mounted) setState(() => _error = '${e.code}: ${e.message}');
    } finally {
      if (mounted) setState(() => _loading = false);
    }
  }

  IconData _batteryIcon(int level) {
    if (level > 80) return Icons.battery_full;
    if (level > 50) return Icons.battery_5_bar;
    if (level > 30) return Icons.battery_3_bar;
    if (level > 10) return Icons.battery_1_bar;
    return Icons.battery_alert;
  }

  Color _batteryColor(int level) {
    if (level > 30) return Colors.green;
    if (level > 10) return Colors.orange;
    return Colors.red;
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Method Channel: Battery')),
      body: Center(
        child: Padding(
          padding: const EdgeInsets.all(24),
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              if (_loading)
                const CircularProgressIndicator()
              else if (_error != null)
                Column(children: [
                  const Icon(Icons.error_outline, size: 48, color: Colors.red),
                  const SizedBox(height: 12),
                  Text('Error: $_error',
                      textAlign: TextAlign.center,
                      style: const TextStyle(color: Colors.red)),
                ])
              else if (_batteryLevel != null)
                Column(children: [
                  Icon(
                    _batteryIcon(_batteryLevel!),
                    size: 72,
                    color: _batteryColor(_batteryLevel!),
                  ),
                  const SizedBox(height: 12),
                  Text(
                    '$_batteryLevel%',
                    style: const TextStyle(
                        fontSize: 52, fontWeight: FontWeight.bold),
                  ),
                  const Text('battery level',
                      style: TextStyle(color: Colors.grey)),
                ])
              else
                const Text(
                  'Press the button to call native code',
                  textAlign: TextAlign.center,
                ),
              const SizedBox(height: 32),
              FilledButton.icon(
                onPressed: _loading ? null : _getBatteryLevel,
                icon: const Icon(Icons.battery_unknown),
                label: const Text('Get battery level'),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

Android — android/app/src/main/kotlin/.../MainActivity.kt

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
package com.example.app

import android.content.Context
import android.os.BatteryManager
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel

class MainActivity : FlutterActivity() {
    private val CHANNEL = "com.example.app/battery"

    override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
        super.configureFlutterEngine(flutterEngine)

        MethodChannel(
            flutterEngine.dartExecutor.binaryMessenger,
            CHANNEL
        ).setMethodCallHandler { call, result ->
            when (call.method) {
                "getBatteryLevel" -> {
                    val level = getBatteryLevel()
                    if (level != -1) {
                        result.success(level)
                    } else {
                        result.error(
                            "UNAVAILABLE",
                            "Could not obtain battery level",
                            null
                        )
                    }
                }
                else -> result.notImplemented()
            }
        }
    }

    private fun getBatteryLevel(): Int {
        val batteryManager =
            getSystemService(Context.BATTERY_SERVICE) as BatteryManager
        return batteryManager
            .getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY)
    }
}

iOS — ios/Runner/AppDelegate.swift

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
import Flutter
import UIKit

@UIApplicationMain
@objc class AppDelegate: FlutterAppDelegate {

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

        let controller = window?.rootViewController as! FlutterViewController
        let batteryChannel = FlutterMethodChannel(
            name: "com.example.app/battery",
            binaryMessenger: controller.binaryMessenger
        )

        batteryChannel.setMethodCallHandler { [weak self] call, result in
            guard call.method == "getBatteryLevel" else {
                result(FlutterMethodNotImplemented)
                return
            }
            self?.getBatteryLevel(result: result)
        }

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

    private func getBatteryLevel(result: FlutterResult) {
        let device = UIDevice.current
        device.isBatteryMonitoringEnabled = true

        guard device.batteryState != .unknown else {
            result(FlutterError(
                code: "UNAVAILABLE",
                message: "Battery level unavailable (simulator or airplane mode)",
                details: nil
            ))
            return
        }
        result(Int(device.batteryLevel * 100))
    }
}

Key concepts

ConceptDetail
MethodChannel('name')Channel identified by a unique name; must match in Dart and native
channel.invokeMethod<T>('method')Calls the native method and awaits T; throws PlatformException on error
result.success(value)Native responds with success and a value
result.error(code, msg, details)Native responds with error → PlatformException in Dart
result.notImplemented()Method is not implemented on this native side
PlatformExceptionTyped error from the native layer; has code and message
configureFlutterEngineAndroid override to register channels
FlutterMethodChanneliOS equivalent of MethodChannel in Dart

Common mistakes

  • Channel name mismatch: if the string in Dart differs from native, invokeMethod returns null or throws. Use a shared constant or document the name clearly.
  • result.success() called multiple times: the native channel can only respond once per call. Calling result.success() twice throws a native exception.
  • Not covering else -> result.notImplemented(): without this case, unknown methods hang indefinitely waiting for a response.
  • Testing on iOS simulator: isBatteryMonitoringEnabled does not work on simulators. Use a physical device or return a mock value in debug mode.

Practical application

Platform channels are the standard path for low-level Bluetooth, NFC, high-frequency accelerometer sensors, custom Face ID/Touch ID, or any system API that existing plugins do not cover. The MethodChannel pattern is the most common; for continuous events from native to Flutter, use EventChannel, which provides a Stream in Dart.

Guided practice and next step

FAQ

Is it better to use an existing plugin or create a custom Method Channel?

Always search pub.dev first. Custom platform channels have a maintenance cost: you need to update native code when the OS API changes. If no plugin exists or the existing ones don’t cover your case, then a custom channel is the right solution.

Can I send data in both directions?

MethodChannel is request-response: Dart calls, native responds. For continuous events from native to Dart (sensors, Bluetooth, real-time geolocation), use EventChannel, which provides a Stream in Dart.

Do Method Channels work on Flutter Web and Desktop?

On Web there is no equivalent native code; use JavaScript interop with dart:js_interop. On Desktop (macOS, Windows, Linux) platform channel equivalents exist with each platform’s APIs.