How do I use a Flutter MethodChannel to invoke a method in dart code from the native swift code?

Viewed 4830

I have looked at many similar questions on this topic, but none of the solutions have worked for me.. I am developing an App in Flutter, but want to call a specific method in my main.dart file from AppDelegate.swift in the native iOS project.

To remove all other variables I have extracted the issue into a fresh dart project. I am trying to call setChannelText() from AppDelegate.swift using methodChannel.invokeMethod(), but with no success.

Does anybody know where I am going wrong? I know I'm not acting upon the "name" parameter in methodChannel.invokeMethod(), but that's because I only want the call to invoke the method at all...

Here is my main.dart file:

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

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

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

class _MyAppState extends State<MyApp> {
  MethodChannel channel =
      new MethodChannel("com.example.channeltest/changetext");
  String centerText;
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        backgroundColor: Colors.purple,
        body: Center(
          child: Text(
            centerText,
            style: TextStyle(
              color: Colors.white,
              fontWeight: FontWeight.bold,
              fontSize: 30.0,
            ),
          ),
        ),
      ),
    );
  }

  @override
  void initState() {
    super.initState();
    this.channel.setMethodCallHandler((call) async => await setChannelText());
    this.centerText = "Hello World!";
  }

  Future setChannelText() async {
    Future.delayed(Duration(milliseconds: 200));
    setState(() => this.centerText = "Another Text.");
  }
}

And here is my AppDelegate.swift file:

import UIKit
import Flutter

@UIApplicationMain
@objc class AppDelegate: FlutterAppDelegate {
    var methodChannel: FlutterMethodChannel!
  override func application(
    _ application: UIApplication,
    didFinishLaunchingWithOptions launchOptions:                 

[UIApplication.LaunchOptionsKey: Any]?
  ) -> Bool {
    
    let rootViewController : FlutterViewController = window?.rootViewController as! FlutterViewController
    methodChannel = FlutterMethodChannel(name: "com.example.channeltest/changetext", binaryMessenger: rootViewController as! FlutterBinaryMessenger)
    
    //This call would obviously be somewhere else in a real world example, but I'm just
    //testing if I can invoke the method in my dart code at all..
    methodChannel.invokeMethod("some_method_name", arguments: nil)
    
    GeneratedPluginRegistrant.register(with: self)
    return super.application(application, didFinishLaunchingWithOptions: launchOptions)
  }
}

In the end, I am trying to get the text to change right after launch, but it doesn't.

Screenshot of app running on iOS simulator

Thanks in advance for any help!!

3 Answers

Issue

The issue is your platform side (iOS in this case) is calling a method on the Flutter side before Flutter is ready. There is no way to check from the platform side, so your Flutter app must tell your platform side. You'll have the same problem on Android.

Solution

To overcome this, you have to tell the Platform side that the app is ready (by sending a platform method) and save it in a boolean, or instantiate a class, and calling a method. Then the platform side can start sending messages.

You should really read the logs, it should warn you something along the lines of: "There is nothing listening to this, or the Flutter Engine is not attached".

import 'dart:async';

import 'package:flutter/src/services/platform_channel.dart';

class StringService {
  final methodChannel =
      const MethodChannel("com.example.app_name.method_channel.strings");

  final StreamController<String> _stringStreamController =
      StreamController<String>();

  Stream<String> get stringStream => _stringStreamController.stream;

  StringService() {
    // Set method call handler before telling platform side we are ready to receive.
    methodChannel.setMethodCallHandler((call) async {
      print('Just received ${call.method} from platform');
      if (call.method == "new_string") {
        _stringStreamController.add(call.arguments as String);
      } else {
        print("Method not implemented: ${call.method}");
      }
    });
    // Tell platform side we are ready!
    methodChannel.invokeMethod("isReady");
  }
}

You can see a working project at reverse_platform_methods, especially AppDelegate.swift. I didn't implement it for Android, but you can do it in a similar way in MainActivity.kt.

Screenshot of iOS app running the project

Question

Most apps don't want code to call from the platform side first. What is your use case? I can possibly provide better advice depending on your answer. I implemented this to handle push notifications being delivered to the device, so the "event" is definitely triggered from the platform side.

Also, you should show errors and warnings if you face them, e.g. No implementation found for method $method on channel $name'.

Well, the problem is all about the initialization process. You try to call your method from swift code BEFORE the dart/flutter part is ready to handle it.

You have to do the next steps to achieve the result:

  1. Important. Use applicationDidBecomeActive method in your AppDelegate for ios
@UIApplicationMain
@objc class AppDelegate: FlutterAppDelegate {
    
 var methodChannel: FlutterMethodChannel? = nil

  override func application(
    _ application: UIApplication,
    didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
  ) -> Bool {

    print("Setup methodChannel from Swift")
    let rootViewController : FlutterViewController = window?.rootViewController as! FlutterViewController
    methodChannel = FlutterMethodChannel(name: "com.example.channeltest/changetext", binaryMessenger: rootViewController as! FlutterBinaryMessenger)

    GeneratedPluginRegistrant.register(with: self)
    return super.application(application, didFinishLaunchingWithOptions: launchOptions)
  }
    
    //THIS METHOD
    override func applicationDidBecomeActive(_ application: UIApplication) {
        methodChannel?.invokeMethod("some_method_name", arguments: "ios string")
    }
}

For Android onStart() method:

class MainActivity : FlutterActivity() {
    var channel: MethodChannel? = null

    override fun configureFlutterEngine(@NonNull flutterEngine: FlutterEngine) {
        super.configureFlutterEngine(flutterEngine)

        channel = MethodChannel(
            flutterEngine.dartExecutor.binaryMessenger,
            "com.example.channeltest/changetext"
        )

    }

    override fun onStart() {
        super.onStart()
        channel?.invokeMethod("some_method_name", "android str")
    }
}
  1. Create your own class with MethodChannel(like from prev. answer)
class TestChannel {
  static MethodChannel channel =
  const MethodChannel("com.example.channeltest/changetext");

  final StreamController<String> _controller =
  StreamController<String>();

  Stream<String> get stringStream => _controller.stream;

  TestChannel() {
    channel.setMethodCallHandler((call) async {
      if (call.method == "some_method_name") {
        _controller.add(call.arguments as String);
      } else {
        print("Method not implemented: ${call.method}");
      }
    });
  }
}
  1. Important. Create it global instance
final _changeTextChannel = TestChannel(); //<--- like this

void main() {
  runApp(MyApp());
}
  1. Handle it in UI
class TestPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
          child: StreamBuilder<String>(
        stream: _changeTextChannel.stringStream,
        builder: (BuildContext context, AsyncSnapshot<String> snapshot) {
          if (snapshot.hasError) {
            return Text("Error");
          }

          if (!snapshot.hasData) {
            return Text("Loading");
          }

          return Text(snapshot.data ?? "NO_DATA");
        },
      )),
    );
  }
}

Flutter side code:

import 'dart:async';

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

class _MyHomePageState extends State<MyHomePage> {
  static const platform = MethodChannel('samples.flutter.dev/battery');

  // Get battery level.
  String _batteryLevel = 'Unknown battery level.';

  Future<void> _getBatteryLevel() async {
    String batteryLevel;
    try {
      final int result = await platform.invokeMethod('getBatteryLevel');
      batteryLevel = 'Battery level at $result % .';
    } on PlatformException catch (e) {
      batteryLevel = "Failed to get battery level: '${e.message}'.";
    }

    setState(() {
      _batteryLevel = batteryLevel;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Material(
      child: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.spaceEvenly,
          children: [
            ElevatedButton(
              child: Text('Get Battery Level'),
              onPressed: _getBatteryLevel,
            ),
            Text(_batteryLevel),
          ],
        ),
      ),
    );
  }
}

Swift code here:

@UIApplicationMain
@objc class AppDelegate: FlutterAppDelegate {
  override func application(
    _ application: UIApplication,
    didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {

    let controller : FlutterViewController = window?.rootViewController as! FlutterViewController
    let batteryChannel = FlutterMethodChannel(name: "samples.flutter.dev/battery",
                                              binaryMessenger: controller.binaryMessenger)
    batteryChannel.setMethodCallHandler({
      [weak self] (call: FlutterMethodCall, result: FlutterResult) -> Void in
      // Note: this method is invoked on the UI thread.
      guard call.method == "getBatteryLevel" else {
        result(FlutterMethodNotImplemented)
        return
      }
      self?.receiveBatteryLevel(result: result)
    })

    GeneratedPluginRegistrant.register(with: self)
    return super.application(application, didFinishLaunchingWithOptions: launchOptions)
  }
}

Or refer to this link:

Platform Channels

Related