DEV Community

Cover image for How to create a custom plugin in Flutter to call native platform code
Christos
Christos

Posted on • Updated on • Originally published at dartling.dev

How to create a custom plugin in Flutter to call native platform code

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, or displaying 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.

flutter create --org dev.dartling --template=plugin --platforms=android,ios app_usage
Enter fullscreen mode Exit fullscreen mode

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.

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

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

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:

 Future<T?> invokeMethod<T>(String method, [ dynamic arguments ])
Enter fullscreen mode Exit fullscreen mode

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)

// 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)
    }
}
Enter fullscreen mode Exit fullscreen mode

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)

// 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)
  }
}
Enter fullscreen mode Exit fullscreen mode

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.

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

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.

// 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() ?? [];
}
Enter fullscreen mode Exit fullscreen mode
// 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),
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

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:

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
Enter fullscreen mode Exit fullscreen mode

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:

Unhandled Exception: MissingPluginException(No implementation found for method getUsedApps on channel app_usage)
Enter fullscreen mode Exit fullscreen mode

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.

// 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()
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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:

// 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),
    )
}
Enter fullscreen mode Exit fullscreen mode

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.

// 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)
    ]
}
Enter fullscreen mode Exit fullscreen mode
// 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)
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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. 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

ios.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.

// 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.';
}
Enter fullscreen mode Exit fullscreen mode

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:

// AppUsageApi.kt
fun setTimeLimit(id: String, durationInMinutes: Int): String {
    return "Timer of $durationInMinutes minutes set for app ID $id";
}
Enter fullscreen mode Exit fullscreen mode
// 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()
    }
}
Enter fullscreen mode Exit fullscreen mode

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:

call.arguments()
Enter fullscreen mode Exit fullscreen mode

We can also check if the argument exists by using:

call.hasArgument("id") // true
call.hasArgument("appId") // false
Enter fullscreen mode Exit fullscreen mode

Next, the iOS/Swift code:

// AppUsageApi
func setTimeLimit(id: String, durationInMinutes: Int) -> String {
    return "Timer of \(durationInMinutes) minutes set for app ID \(id)"
}
Enter fullscreen mode Exit fullscreen mode
// 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)
  }
}
Enter fullscreen mode Exit fullscreen mode

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.

// 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)));
  },
)
Enter fullscreen mode Exit fullscreen mode

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.

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()
    }
Enter fullscreen mode Exit fullscreen mode

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.

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';
  }
}
Enter fullscreen mode Exit fullscreen mode

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, 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, 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.

If you found this helpful and would like to be notified of any future tutorials, please sign up with your email here.

Top comments (0)