Creating Custom Flutter Plugins to Encapsulate Reusable Native Functionality

Flutter, Google’s UI toolkit, allows developers to build natively compiled applications for mobile, web, and desktop from a single codebase. One of Flutter’s standout features is its ability to interact with platform-specific code via plugins. Custom Flutter plugins enable you to encapsulate reusable native functionality, granting your Flutter app access to features not available through Dart alone. This article will guide you through the process of creating custom Flutter plugins to integrate native functionality seamlessly.

What are Flutter Plugins?

Flutter plugins are packages that allow Flutter code to interact with platform-specific APIs, native libraries, or third-party SDKs. These plugins consist of:

  • Dart Code: Defines the API used by your Flutter app.
  • Platform-Specific Code (Android, iOS, Web, etc.): Implements the native functionality on each supported platform.
  • Platform Interface: Establishes communication between Dart and native code using method channels or Pigeon.

Why Create Custom Flutter Plugins?

Creating custom Flutter plugins is beneficial for several reasons:

  • Access Native Features: Use device hardware features, platform-specific services, or APIs unavailable in Flutter/Dart.
  • Code Reusability: Encapsulate reusable native functionality in a modular package.
  • Performance Optimization: Execute performance-critical tasks in native code.
  • Cross-Platform Support: Maintain a consistent Dart API while leveraging native capabilities across different platforms.

Steps to Create a Custom Flutter Plugin

Step 1: Set Up the Plugin Project

Use the Flutter CLI to create a new plugin project:

flutter create --template=plugin my_custom_plugin
cd my_custom_plugin

This command generates a directory structure that includes:

  • lib/my_custom_plugin.dart: Dart API for your plugin.
  • android/: Android-specific code.
  • ios/: iOS-specific code.
  • example/: A Flutter app to test the plugin.

Step 2: Define the Dart API

Open lib/my_custom_plugin.dart and define the public API for your plugin. This involves:

  • Importing flutter/services.dart: Necessary for using MethodChannel to communicate with native code.
  • Creating a Class: Defining the main plugin class.
  • Defining Methods: Creating asynchronous methods that invoke corresponding native methods.
import 'dart:async';

import 'package:flutter/services.dart';

class MyCustomPlugin {
  static const MethodChannel _channel =
      const MethodChannel('my_custom_plugin');

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

  static Future add(int a, int b) async {
    final int? sum = await _channel.invokeMethod('add', {'a': a, 'b': b});
    return sum;
  }
}

Here’s what the code does:

  • A MethodChannel named _channel is created to handle communication.
  • The getPlatformVersion method invokes a native method with the same name.
  • The add method invokes a native method named ‘add’, sending the parameters a and b as arguments in a map.

Step 3: Implement Platform-Specific Code (Android)

Navigate to android/src/main/kotlin/com/example/my_custom_plugin/MyCustomPlugin.kt and implement the Android-specific functionality.

package com.example.my_custom_plugin

import androidx.annotation.NonNull
import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugin.common.MethodChannel.MethodCallHandler
import io.flutter.plugin.common.MethodChannel.Result

class MyCustomPlugin : FlutterPlugin, MethodCallHandler {
  private lateinit var channel : MethodChannel

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

  override fun onMethodCall(@NonNull call: MethodCall, @NonNull result: Result) {
    when (call.method) {
      "getPlatformVersion" -> {
        result.success("Android ${android.os.Build.VERSION.RELEASE}")
      }
        "add" -> {
            val a = call.argument("a") ?: 0
            val b = call.argument("b") ?: 0
            result.success(a + b)
        }
      else -> {
        result.notImplemented()
      }
    }
  }

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

In this Kotlin code:

  • The plugin class implements FlutterPlugin and MethodCallHandler.
  • onAttachedToEngine initializes the MethodChannel.
  • onMethodCall handles method calls from Dart:
    • getPlatformVersion returns the Android version.
    • add retrieves two arguments, adds them, and returns the result.
    • If a method is not implemented, result.notImplemented() is called.
  • onDetachedFromEngine nullifies the MethodChannel.

Step 4: Implement Platform-Specific Code (iOS)

Navigate to ios/Classes/MyCustomPlugin.m (or MyCustomPlugin.swift if using Swift) and implement the iOS-specific functionality.

Objective-C:

#import "MyCustomPlugin.h"
#import 

@implementation MyCustomPlugin
+ (void)registerWithRegistrar:(NSObject*)registrar {
  FlutterMethodChannel* channel = [FlutterMethodChannel
      methodChannelWithName:@"my_custom_plugin"
            binaryMessenger:[registrar messenger]];
  MyCustomPlugin* instance = [[MyCustomPlugin alloc] init];
  [registrar addMethodCallDelegate:instance channel:channel];
}

- (void)handleMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result {
  if ([@"getPlatformVersion" isEqualToString:call.method]) {
    result([@"iOS " stringByAppendingString:[[UIDevice currentDevice] systemVersion]]);
  } else if ([@"add" isEqualToString:call.method]) {
      NSNumber *a = call.arguments[@"a"];
      NSNumber *b = call.arguments[@"b"];
      if (a != nil && b != nil) {
          result(@([a intValue] + [b intValue]));
      } else {
          result([FlutterError errorWithCode:@"INVALID_ARGUMENT"
                                     message:@"Missing argument"
                                     details:nil]);
      }
  } else {
    result(FlutterMethodNotImplemented);
  }
}

@end

Swift:

import Flutter
import UIKit

public class MyCustomPlugin: NSObject, FlutterPlugin {
  public static func register(with registrar: FlutterPluginRegistrar) {
    let channel = FlutterMethodChannel(name: "my_custom_plugin", binaryMessenger: registrar.messenger())
    let instance = MyCustomPlugin()
    registrar.addMethodCallDelegate(instance, channel: channel)
  }

  public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
    switch call.method {
    case "getPlatformVersion":
      result("iOS " + UIDevice.current.systemVersion)
    case "add":
        guard let args = call.arguments as? [String: Any],
              let a = args["a"] as? Int,
              let b = args["b"] as? Int else {
            result(FlutterError(code: "INVALID_ARGUMENT", message: "Missing arguments", details: nil))
            return
        }
        result(a + b)
    default:
      result(FlutterMethodNotImplemented)
    }
  }
}

In both the Objective-C and Swift implementations:

  • registerWithRegistrar/register registers the plugin with Flutter.
  • handleMethodCall/handle handles method calls from Dart:
    • getPlatformVersion returns the iOS version.
    • add retrieves two arguments, adds them, and returns the result.
    • If a method is not implemented, result(FlutterMethodNotImplemented) is called.

Step 5: Test the Plugin

In the example directory of your plugin project, modify the main.dart file to test the plugin.

import 'package:flutter/material.dart';
import 'dart:async';

import 'package:flutter/services.dart';
import 'package:my_custom_plugin/my_custom_plugin.dart';

void main() {
  runApp(MyApp());
}

class MyApp extends StatefulWidget {
  @override
  _MyAppState createState() => _MyAppState();
}

class _MyAppState extends State {
  String _platformVersion = 'Unknown';
  int _sumResult = 0;

  @override
  void initState() {
    super.initState();
    initPlatformState();
  }

  Future initPlatformState() async {
    String? platformVersion;
    try {
      platformVersion = await MyCustomPlugin.getPlatformVersion() ?? 'Unknown platform version';
    } on PlatformException {
      platformVersion = 'Failed to get platform version.';
    }

    int? sum;
    try {
      sum = await MyCustomPlugin.add(5, 3) ?? 0;
    } on PlatformException {
      sum = 0;
    }

    if (!mounted) return;

    setState(() {
      _platformVersion = platformVersion;
      _sumResult = sum!;
    });
  }

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        appBar: AppBar(
          title: const Text('Plugin example app'),
        ),
        body: Center(
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              Text('Running on: $_platformVersionn'),
              Text('5 + 3 = $_sumResultn'),
            ],
          ),
        ),
      ),
    );
  }
}

Here, main.dart calls getPlatformVersion and add methods, displaying the results in the UI.

Step 6: Run the Example App

Navigate to the example directory and run the Flutter app:

cd example
flutter run

Test the plugin on both Android and iOS simulators or physical devices to ensure correct behavior across platforms.

Advanced Considerations

Using Pigeon for Communication

For more complex plugins, consider using Pigeon to generate type-safe communication code. Pigeon simplifies the process of defining the communication interface and generating code for both Dart and native platforms. Define a Pigeon file, generate the code, and use the generated methods to interact with your native functionality.


// pigeon.dart
import 'package:pigeon/pigeon.dart';

@ConfigurePigeon(
  PigeonOptions(
    dartOut: 'lib/src/messages.g.dart',
    kotlinOut:
        'android/src/main/kotlin/com/example/my_custom_plugin/Messages.g.kt',
    swiftOut: 'ios/Classes/Messages.g.swift',
  ),
)
class Input {
  int a;
  int b;
}

class Result {
  int result;
}

@HostApi()
abstract class Api {
  Result add(Input input);
}

Handling Platform Events

For real-time or event-driven interactions, use EventChannel to stream data from native code to Flutter. Create an EventChannel in your Dart code, set up a stream handler in your native code, and send events to the stream.

Packaging and Publishing the Plugin

When your plugin is ready for distribution:

  • Update pubspec.yaml: Include necessary information about your plugin.
  • Document Your Plugin: Provide clear and comprehensive documentation.
  • Publish to pub.dev: Run flutter pub publish to make your plugin available to the Flutter community.

Conclusion

Creating custom Flutter plugins allows developers to leverage the power of native functionalities, enriching Flutter applications beyond the Dart environment. By setting up a plugin project, defining a Dart API, implementing platform-specific code, and thoroughly testing, developers can ensure seamless and efficient native integrations. The steps outlined in this guide provide a comprehensive roadmap for creating, testing, and publishing custom Flutter plugins to extend Flutter’s capabilities. The ability to tap into native resources optimizes performance and extends the functionalities of Flutter applications, paving the way for more versatile and capable apps across various platforms.