Flutter Plugin Development: A Comprehensive Guide: Part 2
By Derrik Fleming | Senior Software Engineer |
In the first part of this series, we explored how to set up a Flutter plugin project using the federated plugin architecture and discussed the core principles of plugin development. Now, in Part 2, we’ll take it a step further by building a federated Flutter plugin that accesses platform sensors, focusing specifically on implementing gyroscope functionality. The foundation we establish here will be adaptable for integrating other types of sensors available on mobile platforms.
While the functionality we’re implementing is similar to what the sensors_plus
package offers, our approach will highlight how to use the federated plugin architecture to directly access gyroscope data. By the end of this tutorial, you’ll know how to create a shared platform interface and implement the required native code in Swift and Kotlin to deliver sensor events to a Flutter application.
To make this guide practical and easy to follow, I’ve set up a GitHub repository that accompanies the tutorial. For every major section, you’ll find permalinks to specific commits that correspond to the discussed changes.
To kick things off, I created a new plugin project tailored to the gyroscope functionality we’ll be working on in this part. Here’s the command used to generate the project:
very_good create flutter_plugin flutter_sensors \ --desc "A federated plugin providing access to platform sensors" \ --platforms android,ios \ --org "com.spindance.tutorial"
Federated Plugin Structure Recap
A federated plugin is structured as a collection of interconnected packages, each serving a specific purpose:
flutter_sensors_platform_interface
: This package defines thePlatformInterface
, which serves as the contract for platform-specific implementations.flutter_sensors_android
: Implements the Android-specific functionality using Kotlin.flutter_sensors_ios
: Implements the iOS-specific functionality using Swift.flutter_sensors_plugin
: A Dart package that provides the public-facing API used by Flutter applications.
Each platform-specific package implements the methods defined in the PlatformInterface
, ensuring platform-appropriate behavior while maintaining a shared interface for the Flutter app. This modular approach is a key strength of the federated plugin architecture and ensures flexibility and maintainability as the plugin evolves.
1. Creating data classes
To effectively represent data coming from native platforms, we need structured classes that encapsulate sensor data. These classes will reside in the PlatformInterface
package, ensuring consistency across platforms. Let’s start by creating an enumeration, SensorType
, which will help us differentiate between various types of sensor data.
Defining Sensor Types
We begin with an enumeration that defines the available sensor types. For now, we’ll focus solely on the gyroscope. However, this setup is extensible, allowing for the addition of other sensor types later.
./flutter_sensors_platform_interface/lib/src/sensor_type.dart
/// Available sensor types enum SensorType { /// The gyro sensor type gyro; @override String toString() => switch (this) { gyro => 'gyro' }; /// Parses a [String] to a [SensorType] static SensorType parse(String value) => switch (value) { 'gyro' => SensorType.gyro, _ => throw FormatException("Unknown SensorType: '$value'"), }; }
See the full diff here.
This enumeration provides a simple way to parse and stringify sensor types. The parse
method ensures that any input string is validated against known types, throwing a FormatException
if the input is invalid. For now, gyro
is the only supported type, but you can extend this to include additional sensor types later. You can add to the values later if you choose to support more types of sensors, but for now we’ll focus on the gyroscope, specifically the rotational values it provides. Looking at the related docs for iOS and Android, we can see that for both platforms the sensor provides three rotational values, one for each of the x, y, and z axis.
Modeling Sensor Data
Next, we need a way to represent sensor data generically while supporting specific implementations for different sensor types. For this, we’ll create an abstract base class, SensorData
, and a specialized class, GyroSensorData
, for gyroscope data.
./flutter_sensors_platform_interface/lib/src/sensor_data.dart
/// Generic wrapper for data from sensors abstract class SensorData { /// Create a [SensorData] SensorData(); /// Creates an instance of SensorData from a decoded JSON blob factory SensorData.fromJson(Map<String, dynamic> json) { final type = SensorType.parse(json['type'].toString()); return switch (type) { SensorType.gyro => GyroSensorData.fromJson(json), }; } }
See the full diff here.
The SensorData
class serves as the base type for all sensor data. Its fromJson
factory method dynamically determines the sensor type from the JSON input and delegates to the appropriate subclass. Currently, it only supports GyroSensorData
, but this design makes it easy to add support for other sensors later.
./flutter_sensors_platform_interface/lib/src/gyro_sensor_data.dart
/// Encapsulates the data obtained from a [SensorType.gyro] class GyroSensorData extends SensorData { /// Creates a new instance of [GyroSensorData] GyroSensorData({ required this.x, required this.y, required this.z, }); /// Creates a new instance of [GyroSensorData] from a decoded JSON blob factory GyroSensorData.fromJson(Map<String, dynamic> json) { if (json['x'] is num && json['y'] is num && json['z'] is num) { return GyroSensorData( x: (json['x'] as num).toDouble(), y: (json['y'] as num).toDouble(), z: (json['z'] as num).toDouble(), ); } else { throw const FormatException('Invalid format for Sensor Data.'); } } /// The X-axis value for [SensorType.gyro] [SensorData] final double x; /// the Y-axis value for [SensorType.gyro] [SensorData] final double y; /// The Z-axis value for [SensorType.gyro] [SensorData] final double z; }
See the full diff here.
The GyroSensorData
class represents the three rotational values (x, y, and z) provided by the gyroscope. These values are parsed from JSON and converted to double
to ensure type consistency. The fromJson
factory method validates the input to guarantee that the expected data is present and correctly formatted. If the input does not meet these criteria, a FormatException
is thrown.
Key Considerations
- Extensibility: Both the
SensorType
andSensorData
classes are designed to support additional sensor types in the future. Adding a new sensor type will only require extending the enumeration, creating a corresponding data class, and updating the factory in the parent classSensorData
. - Manual Serialization: For simplicity, we’re handling serialization manually. This approach avoids dependencies but feel free to use a serialization library (e.g.,
freezed
,json_serializable
) if you prefer.
Now that we have a good idea of what our data will look like, how should the platform provide it to a Flutter application?
Setting up the Platform Interface
To enable communication between Flutter and the native platform, we need to establish a platform interface. In the first part of this series, we explored Flutter’s two main channel types: MethodChannel
and EventChannel
. For our use case, EventChannel
is a natural fit because it allows us to stream continuous sensor data efficiently.
We’ll begin by setting up the EventChannel
in the PlatformInterface
package. Additionally, we’ll configure a default implementation using MethodChannel
, which the very_good_cli
tool has already scaffolded. Let’s extend this setup to include EventChannel
functionality.
Adding the Event Channel
We’ll modify the implementation file to define both a MethodChannel
and an EventChannel
. This configuration ensures we can invoke methods on the native platform and receive real-time data streams.
./flutter_sensors_platform_interface/lib/src/channels_flutter_sensors.dart
/// An implementation of [FlutterSensorsPlatform] that uses method channels. class ChannelsFlutterSensors extends FlutterSensorsPlatform { /// The method channel used to interact with the native platform. @visibleForTesting final methodChannel = const MethodChannel('flutter_sensors'); /// The event channel used to recieve events from the native platform @visibleForTesting final EventChannel eventChannel = const EventChannel('flutter_sensors/sensor_events'); @override Future<String?> getPlatformName() { return methodChannel.invokeMethod<String>('getPlatformName'); } @override Stream<SensorData> get sensorData { throw UnimplementedError(); } }
Here, we’ve introduced an EventChannel
alongside the existing MethodChannel
. While the MethodChannel
is used for one-off calls like retrieving the platform name, the EventChannel
will handle streaming sensor data.
Key Points:
- EventChannel for Streaming: The
eventChannel
is initialized with a unique name,flutter_sensors/sensor_events
, which matches the identifier used in native code to publish sensor events. - Default Implementation: The
sensorData
getter is defined but left unimplemented here. Each platform will provide its own implementation later, ensuring platform-specific functionality.
See the full diff here.
Note: I’ve renamed the above file from it’s original name method_channel_flutter_sensors.dart
, and the class name as well to better reflect it’s contents and purpose. Also, in an effort to show implementations specific to each platform we won’t implement sensorData
here, though we could as our implementations (at least in Dart) will be be identical for each platform.
Updating the Platform Interface
Next, we’ll make adjustments to the PlatformInterface
itself. This step prepares the interface for platform-specific implementations and ensures that our ChannelsFlutterSensors
class serves as the default instance.
./flutter_sensors_platform_interface/lib/flutter_sensors_platform_interface.dart
abstract class FlutterSensorsPlatform extends PlatformInterface { /// Constructs a FlutterSensorsPlatform. FlutterSensorsPlatform() : super(token: _token); static final Object _token = Object(); static FlutterSensorsPlatform _instance = ChannelsFlutterSensors(); /// The default instance of [FlutterSensorsPlatform] to use. /// /// Defaults to [ChannelsFlutterSensors]. static FlutterSensorsPlatform get instance => _instance; /// Platform-specific plugins should set this with their own platform-specific /// class that extends [FlutterSensorsPlatform] when they register themselves. static set instance(FlutterSensorsPlatform instance) { PlatformInterface.verify(instance, _token); _instance = instance; } /// Return the current platform name. Future<String?> getPlatformName(); /// Return a stream of [SensorData] from the native platform Stream<SensorData> get sensorData; }
See the full diff here.
This abstract class defines the contract for all platform-specific implementations, ensuring that every implementation includes:
- A
getPlatformName()
method to identify the platform. - A
sensorData
stream to provide real-time sensor readings.
The instance
getter and setter handle the singleton pattern, ensuring only one implementation is active at a time. By default, this is set to ChannelsFlutterSensors
, but platform-specific packages (e.g., flutter_sensors_android
) will override it during registration.
Implementing the Platform Channels for iOS
With the groundwork for platform channels in place, we can now implement the iOS-specific functionality. This involves creating a concrete Dart implementation for sensorData
and setting up the corresponding EventChannel
for iOS. Then, we’ll move on to implementing the native iOS code using Swift.
Adding the iOS-Specific Dart Implementation
First, we define the iOS-specific implementation in the plugin’s Dart code. This class uses both MethodChannel
and EventChannel
to communicate with the iOS native code.
./flutter_sensors_ios/lib/flutter_sensors_ios.dart
/// The iOS implementation of [FlutterSensorsPlatform]. class FlutterSensorsIOS extends FlutterSensorsPlatform { /// The method channel used to interact with the native platform. @visibleForTesting final methodChannel = const MethodChannel('flutter_sensors_ios'); /// The event channel used to get [SensorData] events from the native platform. @visibleForTesting static const EventChannel sensorChannel = EventChannel('flutter_sensors_ios/sensor_events'); /// Registers this class as the default instance of [FlutterSensorsPlatform] static void registerWith() { FlutterSensorsPlatform.instance = FlutterSensorsIOS(); } @override Future<String?> getPlatformName() { return methodChannel.invokeMethod<String>('getPlatformName'); } @override Stream<SensorData> get sensorData => sensorChannel.receiveBroadcastStream().map( (event) => SensorData.fromJson( jsonDecode(event.toString()) as Map<String, dynamic>, ), ); }
See the full diff here.
MethodChannel
: Used to handle one-time calls like retrieving the platform name (getPlatformName
).EventChannel
: Configured to receive real-time sensor data from the native iOS platform (sensorChannel
).- Mapping Events to
SensorData
: The incoming data from theEventChannel
is JSON-encoded. We decode it and create new instances of theSensorData
class using its named constructor.
Implementing Native Code in Swift
The native iOS implementation handles the heavy lifting of retrieving gyroscope data and passing it to Flutter via the EventChannel
.
./flutter_sensors_ios/ios/Classes/FlutterSensorsPlugin.swift
import CoreMotion import Flutter import UIKit public class FlutterSensorsPlugin: NSObject, FlutterPlugin, FlutterStreamHandler { private var eventSink: FlutterEventSink? private let motionManager = CMMotionManager() public static func register(with registrar: FlutterPluginRegistrar) { let methodChannel = FlutterMethodChannel(name: "flutter_sensors_ios", binaryMessenger: registrar.messenger()) let eventChannel = FlutterEventChannel( name: "flutter_sensors_ios/sensor_events", binaryMessenger: registrar.messenger() ) let instance = FlutterSensorsPlugin() registrar.addMethodCallDelegate(instance, channel: methodChannel) eventChannel.setStreamHandler(instance) } public func handle(_: FlutterMethodCall, result: @escaping FlutterResult) { result("iOS") } public func onListen(withArguments _: Any?, eventSink: @escaping FlutterEventSink) -> FlutterError? { self.eventSink = eventSink startGyroscope() return nil } public func onCancel(withArguments _: Any?) -> FlutterError? { stopGyroscope() eventSink = nil return nil } private func startGyroscope() { if motionManager.isGyroAvailable { motionManager.gyroUpdateInterval = 0.1 motionManager.startGyroUpdates(to: .main) { [weak self] data, _ in if let data = data { self?.sendSensorData( type: "gyro", xValue: data.rotationRate.x, yValue: data.rotationRate.y, zValue: data.rotationRate.z ) } } } } private func stopGyroscope() { motionManager.stopGyroUpdates() } private func sendSensorData(type: String, xValue: Double, yValue: Double, zValue: Double) { let sensorData: [String: Any] = ["type": type, "x": xValue, "y": yValue, "z": zValue] if let jsonData = try? JSONSerialization.data(withJSONObject: sensorData), let jsonString = String(data: jsonData, encoding: .utf8) { eventSink?(jsonString) } } }
See the full diff here.
- Core Motion Framework: Used to fetch gyroscope data via the
CMMotionManager
. - Stream Setup:
- The
EventChannel
is configured inregister(with:)
. - Data is streamed to Flutter using
eventSink
.
- The
- Handling Sensor Data:
- The gyroscope is started when the
onListen
method is triggered. - Gyroscope updates are sent as JSON strings to Flutter via
eventSink
.
- The gyroscope is started when the
Key Points:
- Dart and Native Code Integration: The
FlutterSensorsIOS
Dart class interfaces with the native iOS code through theEventChannel
andMethodChannel
. - Real-Time Data Streaming: Gyroscope data is streamed to Flutter, where it is decoded and converted into
SensorData
objects. - Swift for Native Code: The implementation uses Swift, the default language for Flutter plugins, to interact with the Core Motion framework and handle gyroscope data.
With this setup, the iOS implementation of the plugin is complete. Next, we’ll turn our attention to the Android implementation, which follows a similar structure but uses Android-specific APIs.
Implementing the Platform Channels for Android
ust as with iOS, implementing the Android side of the Platform Interface requires us to connect Flutter to native code. Here, we’re focusing on the same getPlatformName()
function we used in the iOS implementation, and on listening for SensorData
events. The core differences will be in how we handle sensors and the platform-specific integration.
The first step is to implement the Android-specific FlutterSensorsAndroid
class. In this class, we define an EventChannel
to listen for sensor data events coming from the native Android platform.
./flutter_sensors_android/lib/flutter_sensors_android.dart
/// The Android implementation of [FlutterSensorsPlatform]. class FlutterSensorsAndroid extends FlutterSensorsPlatform { /// The method channel used to interact with the native platform. @visibleForTesting final methodChannel = const MethodChannel('flutter_sensors_android'); /// The event channel used to get [SensorData] events from the native platform. @visibleForTesting static const EventChannel sensorChannel = EventChannel('flutter_sensors_android/sensor_events'); /// Registers this class as the default instance of [FlutterSensorsPlatform] static void registerWith() { FlutterSensorsPlatform.instance = FlutterSensorsAndroid(); } @override Future<String?> getPlatformName() { return methodChannel.invokeMethod<String>('getPlatformName'); } @override Stream<SensorData> get sensorData => sensorChannel.receiveBroadcastStream().map((event) { return SensorData.fromJson( jsonDecode(event.toString()) as Map<String, dynamic>, ); }); }
See the full diff here.
In the FlutterSensorsAndroid
class, we handle platform-specific interactions using MethodChannel
and EventChannel
to perform the same actions as in the iOS version. The getPlatformName()
method sends a request to the Android native platform and awaits a response. For the sensor data, we subscribe to the sensorChannel
and map the incoming JSON events into Dart SensorData
objects.
Native Android Code
For the native Android implementation, we use either Java or Kotlin to handle platform-specific logic. In this case, we’ll be working with Kotlin, which is the default for Android development in very_good_cli
. The native Android code listens for sensor events, specifically from the gyroscope, and sends the sensor data to Flutter.
package com.spindance.tutorial import android.content.Context import android.hardware.Sensor import android.hardware.SensorEvent import android.hardware.SensorEventListener import android.hardware.SensorManager import androidx.annotation.NonNull import io.flutter.embedding.engine.plugins.FlutterPlugin import io.flutter.plugin.common.EventChannel import io.flutter.plugin.common.MethodCall import io.flutter.plugin.common.MethodChannel import org.json.JSONObject class FlutterSensorsPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, EventChannel.StreamHandler, SensorEventListener { private lateinit var methodChannel: MethodChannel private lateinit var eventChannel: EventChannel private lateinit var sensorManager: SensorManager private var eventSink: EventChannel.EventSink? = null override fun onAttachedToEngine( @NonNull flutterPluginBinding: FlutterPlugin.FlutterPluginBinding, ) { methodChannel = MethodChannel(flutterPluginBinding.binaryMessenger, "flutter_sensors_android") methodChannel.setMethodCallHandler(this) eventChannel = EventChannel(flutterPluginBinding.binaryMessenger, "flutter_sensors_android/sensor_events") eventChannel.setStreamHandler(this) sensorManager = flutterPluginBinding.applicationContext.getSystemService(Context.SENSOR_SERVICE) as SensorManager } override fun onMethodCall( @NonNull call: MethodCall, @NonNull result: MethodChannel.Result, ) { if (call.method == "getPlatformName") { result.success("Android ${android.os.Build.VERSION.RELEASE}") } else { result.notImplemented() } } override fun onDetachedFromEngine( @NonNull flutterPluginBinding: FlutterPlugin.FlutterPluginBinding, ) { methodChannel.setMethodCallHandler(null) } override fun onListen( arguments: Any?, events: EventChannel.EventSink?, ) { eventSink = events startGyroscope() } override fun onCancel(arguments: Any?) { stopGyroscope() eventSink = null } private fun startGyroscope() { sensorManager.getDefaultSensor(Sensor.TYPE_GYROSCOPE)?.also { gyroscope -> sensorManager.registerListener(this, gyroscope, SensorManager.SENSOR_DELAY_NORMAL) } } private fun stopGyroscope() { sensorManager.unregisterListener(this) } override fun onSensorChanged(event: SensorEvent) { if (event.sensor.type == Sensor.TYPE_GYROSCOPE) { val data = mapOf("type" to "gyro", "x" to event.values[0], "y" to event.values[1], "z" to event.values[2]) val jsonString = JSONObject(data).toString() eventSink?.success(jsonString) } } override fun onAccuracyChanged( sensor: Sensor?, accuracy: Int, ) {} }
See the full diff here.
In the Android implementation, we use the SensorManager
to access the device’s sensors, specifically the gyroscope in this case. We register the sensor listener to start receiving updates and, when sensor data is available, send it to Flutter via the EventChannel
. The data is wrapped in a JSONObject
and serialized to a JSON string before being passed to Flutter.
Key Concepts
- Method Channels: These are used to communicate from Flutter to the native Android code, sending requests and receiving responses. In this case, we use it to get the platform name (
getPlatformName
). - Event Channels: Event channels are used for continuous streams of data from native code to Flutter. The gyroscope data is sent to Flutter through this channel, allowing the Flutter app to react in real-time.
- SensorEventListener: This listener is essential for capturing sensor data in Android. We specifically register for the gyroscope sensor and listen for updates.
By implementing the FlutterSensorsAndroid
class with these native Android components, we have achieved parity with the iOS implementation. Both platforms now have their own distinct code handling platform-specific tasks while adhering to the Flutter plugin architecture. This allows the Flutter app to seamlessly interact with native sensors on both iOS and Android devices.
Example App
Now that we have implemented the plugin and its platform-specific code for both iOS and Android, it’s time to integrate this functionality into a Flutter app. Below is a simple example app that demonstrates how to use the FlutterSensorsPlugin
to display real-time sensor data, such as gyroscope values.
Here, we have a basic MyApp
class, which is the entry point of the application. It uses the SensorDataDisplay
widget to show the sensor data. The app displays gyroscope data using a StreamBuilder
, which listens to a stream of sensor events. When new data is received, it updates the UI.
class MyApp extends StatelessWidget { const MyApp({super.key}); @override Widget build(BuildContext context) { return const MaterialApp(home: SensorDataDisplay()); } }
In the above code, the MyApp
class is a stateless widget that returns a MaterialApp
with the SensorDataDisplay
widget as its home. This is where the sensor data will be displayed to the user.
Next, we define a widget that will handle displaying the gyroscope data, the GyroSensorDataWidget
. This widget takes the sensor data as input and presents it in a column, showing the X, Y, and Z values.
class GyroSensorDataWidget extends StatelessWidget { const GyroSensorDataWidget({ required GyroSensorData data, super.key, }) : _data = data; final GyroSensorData _data; @override Widget build(BuildContext context) { return Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Text( 'Sensor: Gyro Data', style: Theme.of(context).textTheme.headlineSmall, ), Text( 'X: ${_data.x.toStringAsFixed(8)}', style: Theme.of(context).textTheme.bodyLarge, ), Text( 'Y: ${_data.y.toStringAsFixed(8)}', style: Theme.of(context).textTheme.bodyLarge, ), Text( 'Z: ${_data.z.toStringAsFixed(8)}', style: Theme.of(context).textTheme.bodyLarge, ), ], ); } }
The GyroSensorDataWidget
widget takes the GyroSensorData
and displays it in a readable format. The data is presented with precision, showing the X, Y, and Z coordinates of the gyroscope values.
The next part is the SensorDataDisplay
widget, which uses a StreamBuilder
to listen to sensor data and update the UI accordingly. The StreamBuilder
listens for new events and builds the UI based on the type of data received. If the data is of type GyroSensorData
, it will display it using the GyroSensorDataWidget
. If the data type is unknown, it shows a fallback message.
class SensorDataDisplay extends StatelessWidget { const SensorDataDisplay({super.key}); @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text('FlutterSensorsPlugin Example')), body: Center( child: StreamBuilder<SensorData>( stream: sensorData, builder: (context, snapshot) { if (snapshot.connectionState == ConnectionState.waiting) { return const CircularProgressIndicator(); } if (snapshot.hasError) { return Text( 'Error: ${snapshot.error}', style: Theme.of(context).textTheme.headlineSmall, ); } if (!snapshot.hasData) { return const Text('No sensor data available'); } final data = snapshot.data!; return switch (data) { final GyroSensorData d => GyroSensorDataWidget(data: d), _ => const Text('Unknown data type'), }; }, ), ), ); } }
Here, the StreamBuilder
listens for sensor data and reacts to different states like waiting, error, or receiving data. When data arrives, the switch
statement checks if it’s gyroscope data and presents it accordingly. If it’s not recognized, it shows a fallback message.
And that’s it, with any luck you should be able to build the example application. When it installs and launches on your device you’ll see something like the screen recording below.
Wrapping Up
Congratulations! You’ve successfully created a federated Flutter plugin with platform-specific implementations for iOS and Android. By leveraging Platform Channels, you accessed native APIs to fetch gyroscope sensor data, taking a critical step in extending Flutter’s capabilities to interact with native hardware.
In this tutorial, you built the foundation of a robust plugin capable of streaming real-time sensor data to Flutter apps. But this is just the beginning. In the next part of this series, we’ll explore packaging, testing, and deploying your plugin. Our focus will be on making it efficient, reliable, and easy for other Flutter developers to integrate into their apps.
Challenge: Before we continue, take what you’ve learned and add support for another sensor, such as the accelerometer or magnetometer. Think about what would need to be added to support this, and consider any changes that may or may not be necessary to the plugin’s API.
Stay tuned, and happy coding!