Platform channels are the method in which you can write platform-specific native code to use in your Flutter applications. In my case, I needed to wrap a subset of the API for Android’s SpeechRecognizer and iOS’s SFSpeechRecognizer so I could add voice control features to an internal app called Wing Quest. This was an app we created for a fun lunch tradition that helped us find the best wings in town. But more on that later.

The following is a concise selection of code samples showing how to use platform channels to get you up and running as fast as possible. This will be laser focused on the classes and syntax for communicating between Flutter and native, not on the specifics of the feature’s business logic. My hope is for this piece to be a quick and easy reference for those already familiar with the general concept. If you would like a more robust and in-depth introduction please see the links at the end of the article.

*Note – Ancillary code will be omitted and shortcuts taken for brevity such as passing string literals to invokeMethod to maintain focus on platforms channels. Swift will be used for iOS and Kotlin for Android.

Getting Started

To communicate between Flutter and native, you must create a “channel” by creating a channel object on both sides with the same name. In addition to a channel name, the channel objects on the native side also take a “Binary Messenger.” This is typically going to be the binaryMessenger property of your rootViewController on iOS and the flutterView property of the MainActivity on Android.

These method channel objects each have an invokeMethod and a setMethodHandler method. If you call invokeMethod on the method channel object on the Flutter side it will call the function specified by setMethodHandler on the native side and vice-versa. In addition to the method channel there is also an event channel which will be shown at the end of this article. This is more of a publish-subscribe pattern that leverages Dart’s streams and, while not necessary for any particular use-case, may be desired to preserve this style when sending platform events to Flutter or when dealing with multiple listeners in different contexts.

Simplest Example – Calling Native from Flutter

Here we create a method channel object in Flutter, iOS, and Android with the same name. The Android and iOS objects will set a method call handler to receive calls from Flutter. The Flutter code can then call invokeMethod to call the handlers on the native objects.


    // Flutter
    static final channelName = 'wingquest.stablekernel.io/speech';
    final methodChannel = MethodChannel(channelName);
    
    await this.methodChannel.invokeMethod("start");

    // iOS - AppDelegate.swift
    
    let channelName = "wingquest.stablekernel.io/speech"
    let rootViewController : FlutterViewController = window?.rootViewController as! FlutterViewController
    
    let methodChannel = FlutterMethodChannel(name: channelName, binaryMessenger: rootViewController)

    methodChannel.setMethodCallHandler {(call: FlutterMethodCall, result: FlutterResult) -> Void in
        if (call.method == "start") {
            try? speechController.startRecording()
        }
    }

    // Android - MainActivity.kt
    
    private val channelName = 'wingquest.stablekernel.io/speech'
    
    MethodChannel(flutterView, channelName).setMethodCallHandler { call, result ->
      if (call.method == "start") {
        speechRecognizer?.startListening(recognizerIntent);
      }
    }

Passing Arguments, Returning Values, and Throwing Errors

Pass an argument to the method call handler by simply providing any of the basic types as the second argument when calling invokeMethod. Use the result callback in setMethodCallHandler to return a result or throw an error. To return a value simply pass result the value to be returned. If an error occurs pass result a FlutterError object. If the method was unknown, pass result a FlutterMethodNotImplemented object.


    // Flutter
    // Here we simply pass a string but this could easily be a Map if you want to pass multiple named arguments
    final String argument = "en-US";
    await this.methodChannel.invokeMethod("start", argument);

    final List<dynamic> channelList = await methodChannel.invokeMethod("getSupportedLocales");
    // must be cast from List<dynamic> to the expected type
    final List<String> locales = channelList.cast<String>(); 

    // iOS - AppDelegate.swift
    methodChannel.setMethodCallHandler {(call: FlutterMethodCall, result: FlutterResult) -> Void in
        switch call.method {
        case "getSupportedLocales":
            let locales = speechController.getSupportedLocales().map({ $0.identifier })
            result(locales)
        case "start":
            do {
                let locale = call.arguments as! String
                try speechController.startRecording(locale: locale)
            }
            catch {
                let errorMessage = "Failed to start speech controller: " + error.localizedDescription
                let error = FlutterError(code: "418", message: errorMessage, details: nil)
                result(error)
            }
        case "stop":
            speechController.stopRecording()
        default:
            result(FlutterMethodNotImplemented)
        }
    }


    // Android - MainActivity.kt
    MethodChannel(flutterView, channelName).setMethodCallHandler { call, result ->
      val locale: String = (call.arguments as? String)  ?: "en-US";
      val recognizerIntent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH);
      val nullErrorMessage = "Failed to start speech controller: speechController is null";
      
      when (call.method) {
        "getSupportedLocales" -> result.success(speechRecognizer?.getSupportedLocales(context))
                ?: result.error("418", "nullErrorMessage", null);
        "start" -> speechRecognizer?.startListening(recognizerIntent) 
                ?: result.error("418", "nullErrorMessage", null);
        "stop" ->  speechRecognizer?.stopListening() 
                ?: result.error("418", "nullErrorMessage", null);
        else -> result.notImplemented()
      }
    }

New call-to-action

 

Reverse Example – Calling Flutter from Native

If you need to call a method defined in Flutter from native code then you call the same methods from earlier, but this time you call setMethodCallHandler on the Flutter side and invokeMethod on the native side.

    
    // Flutter
    final channelName = 'wingquest.stablekernel.io/speech';

    final methodChannel = MethodChannel(channelName);
    methodChannel.setMethodCallHandler(this._didRecieveTranscript);

    Future<void> _didRecieveTranscript(MethodCall call) async {
      // type inference will work here avoiding an explicit cast
      final String utterance = call.arguments; 
      switch(call.method) {
        case "didRecieveTranscript":
          processUtterance(utterance);
      }
    }
    
    // iOS - AppDelegate.swift
    
    let rootViewController : FlutterViewController = window?.rootViewController as! FlutterViewController
    let channelName = "wingquest.stablekernel.io/speech"
    let methodChannel = FlutterEventChannel(name: channelName, binaryMessenger: rootViewController)
    
    methodChannel.invokeMethod("didRecieveTranscript", utterance)
    // Android - MainActivity.kt
    
    val channelName = 'wingquest.stablekernel.io/speech'
    val methodChannel = MethodChannel(flutterView, channelName)
    
    methodChannel.invokeMethod("didRecieveTranscript", utterance)

Event Streaming

Events can only be streamed from native to Flutter currently. In the previous examples we used MethodChannel and gave it a handler function on the receiving side and called invokeMethod on the sending side. The streaming strategy is similar in that we set a handler function on the receiving side but the sending side is now a class that handles sending events. The class must implement a stream controller interface consisting of onListen, which is called the first time a stream registered to listen to the stream’s events, and onCancel which is called when the last listener is deregistered from the Stream. The onListen function also gives you the eventSink which we will store in a property to use later when sending the events. In this example these classes also conform to a delegate protocol which guarantees the presence of a speechController method the native code can call every time speech is detected.


 // Flutter
 final eventChannel = EventChannel("wingquest.stablekernel.io/speech");
 this.eventChannel.receiveBroadcastStream().listen(speechResultsHandler, onError: speechResultErrorHandler);

 speechResultsHandler(dynamic event) {
   final String normalizedEvent = event.toLowerCase();
   _scaffoldKey.currentState.showSnackBar(SnackBar(content: Text(normalizedEvent)));

   final detectedRating = this.detectRating(normalizedEvent);
   
   if (detectedRating == null) {
     return;
   }
   updateStepAndRating(normalizedEvent, detectedRating);
 }

 speechResultErrorHandler(dynamic error) => print('Received error: ${error.message}');

}

    // iOS
    
    // SpeechRecognitionStreamHandler.swift
    class SpeechRecognitionStreamHandler: NSObject, FlutterStreamHandler, SpeechControllerDelegate {
        private var _eventSink: FlutterEventSink?
    
        func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? {
            _eventSink = events
            return nil
        }
    
        func onCancel(withArguments arguments: Any?) -> FlutterError? {
            _eventSink = nil
            return nil
        }
        // This method is called by the speech controller as mentioned above
        func speechController(_ speechController: SpeechController, didRecieveTranscript transcript: String) {
            _eventSink?(transcript)
        }
    }
    
    // AppDelegate.swift
    
    let handler = SpeechRecognitionStreamHandler()
    let speechController = SpeechController()
    // The speech controller will call the didRecieveTranscript method on the handler for each detected utterance.
    speechController.delegate = handler 
        
    let eventChannelName = "wingquest.stablekernel.io/speech"

    let eventChannel = FlutterEventChannel(name: eventChannelName, binaryMessenger: rootViewController)
    eventChannel.setStreamHandler(handler)    
    // Android
    
    // SpeechResultStreamHandler.kt
    
    class SpeechResultStreamHandler(): EventChannel.StreamHandler, SpeechControllerDelegate {
      var _eventSink: EventChannel.EventSink? = null
    
      override fun onListen(p0: Any?, p1: EventChannel.EventSink?) {
        this._eventSink = p1
      }
    
      override fun onCancel(p0: Any?) {
        this._eventSink = null
      }
    
      // This method is called by the speech listener as mentioned above
      override fun speechController(text: String) {
        this._eventSink?.success(text)
      }
    }
    
    // MainActivity.kt
    private val streamHandler = SpeechResultStreamHandler()
    private val eventChannelName = "wingquest.stablekernel.io/speech"

    override fun onCreate(savedInstanceState: Bundle?) {
      super.onCreate(savedInstanceState)
      GeneratedPluginRegistrant.registerWith(this)
    
      // Method controller code from earlier hidden here for clarity
    
      EventChannel(flutterView, eventChannelName).setStreamHandler(streamHandler)
    
      // Speech listener will call speechController(text: String) on the handler
      speechListener.delegate = streamHandler
      speechRecognizer = SpeechRecognizer.createSpeechRecognizer(flutterView.context)
      speechRecognizer?.setRecognitionListener(speechListener)
    
      requestPermission()
    }

So there you have it! I hope this guide was helpful in getting you up and running quickly with Flutter platform channels. If you have a unique use case, want to see more examples or just want a deeper understanding of what is available, I’ve linked to some useful resources below:

Jon Day

Software Engineer at Stable Kernel

Leave a Reply

Your email address will not be published. Required fields are marked *