How to create a custom plugin in Flutter to call native platform code
Tags: flutter, dart, plugin
Published: 2022-07-04 19:16:00

# Introduction
In this tutorial, we will create a Flutter plugin which targets the Android and iOS platforms, and show how to invoke different methods from Dart, pass arguments of different types, and receive and parse results from the host platforms. The platform code won't actually call any real native APIs, but rather return some hard-coded data. But by the end of this tutorial, doing that part should hopefully be easy!

Flutter is mainly a UI framework, and so for a lot platform-specific functionality, we usually use plugins, typically created by the Dart and Flutter community, to achieve things such as [getting the current battery level](https://pub.dev/packages/battery_plus), or [displaying local notifications](https://pub.dev/packages/flutter_local_notifications). However, in some cases, there might not be a plugin already available, or the platform we are targeting might not be supported. In such cases, writing our own custom plugin (or contributing to an existing plugin), might be our only option.

# Starting with the plugin template
To get started, we'll create a Flutter plugin using `flutter create` with the plugin template.

```sh
flutter create --org dev.dartling --template=plugin --platforms=android,ios app_usage
```

This will generate the code for the plugin, as well as an example project that uses this plugin. By default, the generated Android code will be in Kotlin, and iOS in Swift, but you can specify either Java or Objective-C with the `-a` and `-i` flags respectively. (`-a java` and/or `-i objc`).

Of course, you could target more platforms if you want. For this tutorial, we will only be targeting Android and iOS.

Next, let's take a look at the generated Dart, Kotlin and Swift code after running `flutter create`.

## Dart code
This auto-generated plugin is an `AppUsage` Dart class with a `MethodChannel`, and a method which invokes a specific method name in this channel. This `getPlatformVersion` method is implemented in both Android and iOS to return the current platform version.

```dart
class AppUsage {
  static const MethodChannel _channel = MethodChannel('app_usage');

  static Future<String?> get platformVersion async {
    final String? version = await _channel.invokeMethod('getPlatformVersion');
    return version;
  }
}
```

The `MethodChannel` constructor accepts a channel name, which is the name of the channel on which communication between the Dart code and the host platform will happen. This name is typically the plugin name, and this is what the generated code uses, but some plugins usually use a combination of the application package/ID or domain. So for this example we could go for something like `dev.dartling.app_usage` or `dartling.dev/app_usage`.

Let's dig a little deeper into  `MethodChannel#invokeMethod`:

```dart
 Future<T?> invokeMethod<T>(String method, [ dynamic arguments ])
```

We can specify which type we expect to be returned by the method channel, and the future's result can always be null. So in the `platformVersion` getter above, we could be more explicit and use `_channel.invokeMethod<String>('getPlatformVersion')` instead.

## Kotlin code (Android)
```kotlin
// AppUsagePlugin.kt
class AppUsagePlugin : FlutterPlugin, MethodCallHandler {
    private lateinit var channel: MethodChannel

    override fun onAttachedToEngine(@NonNull flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
        channel = MethodChannel(flutterPluginBinding.binaryMessenger, "app_usage")
        channel.setMethodCallHandler(this)
    }

    override fun onMethodCall(@NonNull call: MethodCall, @NonNull result: Result) {
        if (call.method == "getPlatformVersion") {
            result.success("Android ${android.os.Build.VERSION.RELEASE}")
        } else {
            result.notImplemented()
        }
    }

    override fun onDetachedFromEngine(@NonNull binding: FlutterPlugin.FlutterPluginBinding) {
        channel.setMethodCallHandler(null)
    }
}
```

The `onAttachedToEngine` and `onDetachedFromEngine` methods are pretty standard and we won't have to touch them at all. One note about `onAttachedToEngine`, is the `MethodChannel` constructor which also accepts a channel name. This name should match whatever we pass in the constructor on the Flutter side of things, so if you decide to go with a different name, make sure you change it in the constructors of all platforms.

The `onMethodCall` method is where the bulk of the logic happens, and will happen, when we add more functionality to our plugin. This method accepts two parameters. The first, the `MethodCall`, contains the data we pass from the `invokeMethod` invocation in Dart. So, `MethodCall#method` will return the method name (`String`), and `MethodCall#arguments` contains any arguments we pass along with the invocation. `arguments` is an `Object`, and can be used in different ways, but more on that later.

The `Result` can be used to return data or errors to Dart. This must _always_ be used, otherwise the `Future` will never complete, and the invocation would hang indefinitely. With `result`, we can use the `success(Object)` method to return any object, `error(String errorCode, String errorMessage, Object errorDetails)` to return errors, and `notImplemented()` if the method we are invoking is not implemented (this is what happens in the `else` block above).

## Swift code (iOS)
```swift
// SwiftAppUsagePlugin.swift
public class SwiftAppUsagePlugin: NSObject, FlutterPlugin {
  public static func register(with registrar: FlutterPluginRegistrar) {
    let channel = FlutterMethodChannel(name: "app_usage", binaryMessenger: registrar.messenger())
    let instance = SwiftAppUsagePlugin()
    registrar.addMethodCallDelegate(instance, channel: channel)
  }

  public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
    result("iOS " + UIDevice.current.systemVersion)
  }
}
```

Note that in the generated Swift code, there are no checks for the `getPlatformVersion` method name. Let's make some changes to the `handle` method, to keep things consistent across the two platforms.

```swift
public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
if (call.method == "getPlatformVersion") {
    result("iOS " + UIDevice.current.systemVersion)
} else {
    result(FlutterMethodNotImplemented)
}
```

Similarly to Android/Kotlin, `FlutterMethodCall` has a `method` string and dynamic `arguments`. But `FlutterResult` is a bit different. For a "successful" return, you can just pass any value in `result(...)`. If the method is not implemented, just pass `FlutterMethodNotImplemented`, as shown above. And for errors, pass `FlutterError.init(code: "ERROR_CODE", message: "error message", details: nil)`.

# Returning complex objects
Now that we've seen how the code looks across Dart and our target platforms, let's implement some new functionality. Let's say that our App Usage plugin should return a list of the used apps, showing how much time we spend on each app.

```dart
// app_usage.dart
static Future<List<UsedApp>> get usedApps async {
  final List<dynamic>? usedApps =
      await _channel.invokeListMethod<dynamic>('getUsedApps');
  return usedApps?.map(UsedApp.fromJson).toList() ?? [];
}
```

```dart
// models.dart
class UsedApp {
  final String id;
  final String name;
  final Duration timeUsed;

  UsedApp(this.id, this.name, this.timeUsed);

  static UsedApp fromJson(dynamic json) {
    return UsedApp(
      json['id'] as String,
      json['name'] as String,
      Duration(minutes: json['minutesUsed'] as int),
    );
  }
}
```

Notice that we actually expect a `List<dynamic>` rather than `List<UsedApp>` when we invoke a method from the channel, and map these to `UsedApp` using the `fromJson` method. This is because we cannot just cast complex objects, though this will work fine for simple types such as `int`, `double`, `bool` and `String`. Calling `_channel.invokeMethod<UsedApp>(...)` will result to this error:

```sh
The following _CastError was thrown building MyApp(dirty, state: _MyAppState#8bcb2):
type '_InternalLinkedHashMap<Object?, Object?>' is not a subtype of type 'UsedApp' in type cast
```

Also notice that we used the convenience `invokeListMethod<T>`, since we are expecting a list of items to be returned. The above method is equivalent to `_channel.invokeMethod<List<dynamic>>(...)`. There is also the `invokeMapMethod<K, V>` if we are expecting a map.

Now, let's implement `getUsedApps` on the Android and iOS platforms. If we don't, and try to invoke this method from the example app (or any app), we will see this error:

```sh
Unhandled Exception: MissingPluginException(No implementation found for method getUsedApps on channel app_usage)
```

For Android, we have to update our `onMethodCall` function in `AppUsagePlugin`. We replace the `if` statement with a `when`, to make things a bit simpler.

```kotlin
// AppUsagePlugin.kt
class AppUsagePlugin : FlutterPlugin, MethodCallHandler {
    private var appUsageApi = AppUsageApi()

    override fun onMethodCall(@NonNull call: MethodCall, @NonNull result: Result) {
        when (call.method) {
            "getPlatformVersion" -> result.success("Android ${android.os.Build.VERSION.RELEASE}")
            "getUsedApps" -> result.success(appUsageApi.usedApps.stream().map { it.toJson() }
                .toList())
            else -> result.notImplemented()
        }
    }
}
```

When invoking `getUsedApps`, we simply use the `AppUsageApi` to return the used apps, map them to a list of JSON objects (actually just a map of string to a value), and return them with `result`.

This is what `AppUsageApi` looks like, if you're curious:

```kotlin
// AppUsageApi.kt
data class UsedApp(val id: String, val name: String, val minutesUsed: Int) {
    fun toJson(): Map<String, Any> {
        return mapOf("id" to id, "name" to name, "minutesUsed" to minutesUsed)
    }
}

class AppUsageApi {
    val usedApps: List<UsedApp> = listOf(
        UsedApp("com.reddit.app", "Reddit", 75),
        UsedApp("dev.hashnode.app", "Hashnode", 37),
        UsedApp("link.timelog.app", "Timelog", 25),
    )
}
```

Just a data class and some hard-coded values. We could have made this simpler and just returned a `Map<String, Any>` straight from here, but realistically, an API would return its own data classes/models.

Similarly, for iOS, we need to update the `handle` function in `SwiftAppUsagePlugin`.
```swift
// AppUsageApi
struct UsedApp {
    var id: String
    var name: String
    var minutesUsed: Int

    func toJson() -> [String: Any] {
        return [
            "id": id,
            "name": name,
            "minutesUsed": minutesUsed
        ]
    }
}

class AppUsageApi {
    var usedApps = [
        UsedApp(id: "com.reddit.app", name: "Reddit", minutesUsed: 75),
        UsedApp(id: "dev.hashnode.app", name: "Hashnode", minutesUsed: 37),
        UsedApp(id: "link.timelog.app", name: "Timelog", minutesUsed: 25)
    ]
}
```

```swift
// SwiftAppUsagePlugin.swift
public class SwiftAppUsagePlugin: NSObject, FlutterPlugin {
  private var appUsageApi = AppUsageApi()

  public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
    switch (call.method) {
        case "getPlatformVersion":
            result("iOS " + UIDevice.current.systemVersion)
        case "getUsedApps":
            result(appUsageApi.usedApps.map { $0.toJson() })
        default:
            result(FlutterMethodNotImplemented)
    }
  }
}
```

I will not be sharing many snippets from the example app and usages of the `AppUsage` functions, just to keep the tutorial shorter, but you can take a look at the source code [here](https://github.com/dartling/app_usage). But the actual usage of the plugin is quite simple. We are simply calling the static methods of `AppUsage` to get data from the host platform, and display it. But in case you're curious to see the method in action, this is how the example app looks like:

![android.png](https://cdn.hashnode.com/res/hashnode/image/upload/v1648588947386/x81wxSmPZ.png)

![ios.png](https://cdn.hashnode.com/res/hashnode/image/upload/v1648588469952/DCIGFo5NI.png)

# Passing arguments
So far, we've shown how to receive data from the host platform. Now what if we want to pass data instead? Let's introduce a new method to our App Usage API.

```dart
// app_usage.dart
static Future<String> setAppTimeLimit(String appId, Duration duration) async {
  final String? result = await _channel.invokeMethod('setAppTimeLimit', {
    'id': appId,
    'durationInMinutes': duration.inMinutes,
  });
  return result ?? 'Could not set timer.';
}
```

The difference with the previous method is that we're now also passing `parameters` to `invokeMethod`, which is an optional field. While the type of `parameters` is dynamic, and so could be anything, it's recommended to use a map.

Since our implementation won't actually set any app time limits, it would still be nice to confirm that the host platform has properly received the passed parameters, in our case `id` and `minutes`. So to keep things simple, we just want to return a string containing a confirmation that the time limit was set for the given app ID and duration.

Here's the Android/Kotlin implementation:
```kotlin
// AppUsageApi.kt
fun setTimeLimit(id: String, durationInMinutes: Int): String {
    return "Timer of $durationInMinutes minutes set for app ID $id";
}
```
```kotlin
// AppUsagePlugin.kt
override fun onMethodCall(@NonNull call: MethodCall, @NonNull result: Result) {
    when (call.method) {
        ...
        "setAppTimeLimit" -> result.success(
            appUsageApi.setTimeLimit(
                call.argument<String>("id")!!,
                call.argument<Int>("durationInMinutes")!!
            )
        )
        else -> result.notImplemented()
    }
}
```

We get the arguments from the passed parameters using `MethodCall#argument`, and specify the type we expect the argument to have. This method only works if the parameters passed are either a map or a `JSONObject`. The method returns an optional result we could be null, hence the `!!` operator. If the argument for that key in the map is missing or has a different type, an exception is thrown.

Alternatively, we could return the whole map by using:
```kotlin
call.arguments()
```
We can also check if the argument exists by using:
```kotlin
call.hasArgument("id") // true
call.hasArgument("appId") // false
```

Next, the iOS/Swift code:
```swift
// AppUsageApi
func setTimeLimit(id: String, durationInMinutes: Int) -> String {
    return "Timer of \(durationInMinutes) minutes set for app ID \(id)"
}
```
```swift
// SwiftAppUsagePlugin.swift
public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
  switch (call.method) {
      ...
      case "setAppTimeLimit":
          let arguments = call.arguments as! [String: Any]
          let id = arguments["id"] as! String
          let durationInMinutes = arguments["durationInMinutes"] as! Int
          result(appUsageApi.setTimeLimit(id: id, durationInMinutes: durationInMinutes))
      default:
          result(FlutterMethodNotImplemented)
  }
}
```
Very similar, but unlike Kotlin, there are no convenience methods to get an argument by its key. Instead, we need to cast `call.arguments` to a map of `String` to `Any`, and then cast each argument to the type we expect it in. Both the arguments and any values in the map can be null, which is why we need the `!` operator when casting.

And that's it for the platform implementations! In the example app, I've added an icon button which calls this method and displays snackbar with the result string.

```dart
// EXAMPLE APP: main.dart
IconButton(
  icon: const Icon(Icons.timer_outlined),
  onPressed: () async {
    // TODO: Set duration manually.
    final String result = await AppUsage.setAppTimeLimit(
        app.id, const Duration(minutes: 30));
    ScaffoldMessenger.of(context)
        .showSnackBar(SnackBar(content: Text(result)));
  },
)
```

# Error handling
We've now learned how to read data returned from the host platform, and pass data to the host platform. For the last part, we'll return an error from the platform side and catch it.

For this example, we'll just be doing this on the Android side. Let's improve the implementation for `setAppTimeLimit`.

```kotlin
override fun onMethodCall(@NonNull call: MethodCall, @NonNull result: Result) {
    when (call.method) {
        ...
        "setAppTimeLimit" -> {
            if (!call.hasArgument("id") || !call.hasArgument("durationInMinutes")) {
                result.error(
                    "BAD_REQUEST",
                    "Missing 'id' or 'durationInMinutes' argument",
                    Exception("Something went wrong")
                )
            }
            result.success(
                appUsageApi.setTimeLimit(
                    call.argument<String>("id")!!,
                    call.argument<Int>("durationInMinutes")!!
                )
            )
        }
        else -> result.notImplemented()
    }
```
If either the `id` or `durationInMinutes` arguments are missing from the method call, we'll throw a more helpful exception. Otherwise, we'd just get a null pointer exception when calling `call.argument<T>("key")!!`.

This results in a `PlatformException` being thrown from `invokeMethod` on the Flutter side. To handle it, we could do the following.

```dart
static Future<String> setAppTimeLimit(String appId, Duration duration) async {
  try {
    final String? result = await _channel.invokeMethod('setAppTimeLimit', {
      'appId': appId,
      'durationInMinutes': duration.inMinutes,
    });
    return result ?? 'Could not set timer.';
  } on PlatformException catch (ex) {
    return ex.message ?? 'Unexpected error';
  }
}
```
In the snippet above we replaced the `id` argument with `appId`, which will lead to a platform exception.

# Alternatives
One not-so-nice aspect of host platform to Flutter communication with `MethodChannel` is the serialization/deserialization part. When passing many arguments, we need to pass them in a map, and accessing them, casting them, and checking if they exist is not very nice. Same for parsing data returned from the method calls; for this tutorial we needed to map the `UsedApp` list to a JSON-like map from the Kotlin/Swift code, and then implement a method to create a `UsedApp` from the returned list on the Flutter side. This can be time-consuming, but also error-prone (all our fields/keys are hard-coded strings and have to be kept in sync across 3 different languages!).

Enter [Pigeon](https://pub.dev/packages/pigeon), an alternative to `MethodChannel` for Flutter to host platform communication. It is a code generator tool which aims to make this type-safe, easier and faster. With Pigeon, you just have to define the communication interface, and code generation takes care of everything else. In [this post](https://dartling.dev/how-to-create-a-custom-plugin-in-flutter-with-pigeon), we explore using Pigeon as an alternative to method channels and build the exact same functionality as with this tutorial. If you're curious, check it out and see how it compares!

# Wrapping up
In this tutorial, we created a custom Flutter plugin with both Android and iOS implementations to call (fake) native functionality. We showed how to send and retrieve data from the host platforms, as well as throw errors and handle them.

You can find the full source code [here](https://github.com/dartling/app_usage).

If you found this helpful and would like to be notified of any future tutorials, please sign up with your email [here](https://www.getrevue.co/profile/dartling).