Flutter Plugin Playground

Back to Blog Home

Flutter Plugin Playground

Hi everyone! We’d like to share our own plugin playground which allow us to test new ideas on Flutter. You can easily build this playground from scratch but doing it every time you need to test some stuff can be time consuming. Feel free to clone the repo and modify it to your own needs. This post will explain each step needed to create such a project by yourself.

If you haven’t written any platform specific code in Flutter before, you may checkout this tutorial as a start.

Ingredients

  1. An app that shows logs and holds the playground
  2. A play button to run the playground code
  3. A pair of channels to communicate with Android or iOS native code
  4. A way to match channel invocations to native methods by name look up (via reflection)

The Usual Suspects

First, make sure you have a working flutter installation by running this command.

flutter doctor

Proceed to the steps shown on your terminal to fix your Flutter installation if necessary. Then, run the command below to create your playground project.

flutter create flutter_plugin_playground

Use these commands in your project folder whenever you need to make changes on the native side.

flutter devices #see the list of device and ids
flutter run -d <device_id> #run on device

Luckily, changing dart code automatically triggers a hot-swap which will allow us to see the changes immediately.

Open up your favorite IDE and you are good to go.

Playground UI

Open main.dart file to start implementing your playground front-end.

We will need Material UI styles to fashion up our widgets. Services will provide as the platform channels we need to communicate with the native side.

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

Let’s create our singleton channel first.

const channel = const MethodChannel('playground');

Before invoking our app main, we need to define a stateful widget to hold our code and logs. This widget will store logs in a widget local variable and provide utilities to automatically append logs with newlines. It will also include our main function to call when we run our playground.

class PlaygroundState extends State<Playground> {
  @override
  Widget build(BuildContext context) {
    // TODO : playground UI
  }

  /////////////// Playground ///////////////////////////////////////////////////
  String logs = "";

  // Call inside a setState({ }) block to be able to reflect changes on screen
  void log(String logString) {
    logs += logString.toString() + "\n";
  }

  // Main function called when playground is run
  void runPlayground() async {
    // This will not work until we implement a test() method on the native side
    var testResult = await channel.invokeMethod("test");

    // Update state and UI
    setState(() {
      log(testResult);
    });
  }
}

Finally, it will show our logs and play button in a user friendly manner.

@override
Widget build(BuildContext context) {

  return Scaffold(
    appBar: AppBar(
      title: Text(widget.title),
    ),
    body: Center(
      child: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        children: <Widget>[
          Text("Press button to run playground"),
          Text("-"),
          Text(logs)
        ],
      ),
    ),
    floatingActionButton: FloatingActionButton(
      onPressed: runPlayground,
      tooltip: 'Run Playground',
      child: Icon(Icons.play_arrow),
    )
  );
}

Once our stateful widget is ready, we can attach it to our app with the following wrapper.

class Playground extends StatefulWidget {
  Playground({Key key, this.title}) : super(key: key);

  final String title;

  @override
  PlaygroundState createState() => PlaygroundState();
}

class PlaygroundApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Plugin Playground',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: Playground(title: 'Plugin Playground'),
    );
  }
}

Then, we can run our app with this simple line.

void main() => runApp(PlaygroundApp());

Android Native Plugin

Now that we have a working front-end with a properly defined channel, we can fill the missing pieces on Java to complete the circle.

Open MainActivity.java file.

Use the lines below when your IDE asks you to choose which classes to import.

import android.os.Bundle;
import android.util.Log;

import java.lang.reflect.Method;

import io.flutter.app.FlutterActivity;
import io.flutter.plugin.common.MethodCall;
import io.flutter.plugin.common.MethodChannel;
import io.flutter.plugins.GeneratedPluginRegistrant;
import io.flutter.view.FlutterView;

We will create a channel with the same name we used in the dart side.

public class MainActivity extends FlutterActivity {
  private MethodChannel channel;

  @Override
  protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    GeneratedPluginRegistrant.registerWith(this);

    // Prepare channel
    channel = new MethodChannel(getFlutterView(), "playground");
    channel.setMethodCallHandler(new MethodChannel.MethodCallHandler() {
      @Override
      public void onMethodCall(MethodCall methodCall, MethodChannel.Result result) {
        // TODO : invoke native method
      }
    });
  }
}

When dart code calls invokeMethod() on a channel, it specifies a method name. That name is accessed via methodCall.method property. We can either compare that name in a switch block or allow java reflection to do that for us automatically.

channel.setMethodCallHandler(new MethodChannel.MethodCallHandler() {
  @Override
  public void onMethodCall(MethodCall methodCall, MethodChannel.Result result) {
    try {
      // Find a method with the same name in activity
      Method method = MainActivity.class.getDeclaredMethod(
              methodCall.method,
              Object.class,
              MethodChannel.Result.class
      );

      // Call method if exists
      method.setAccessible(true);
      method.invoke(MainActivity.this, methodCall.arguments, result);
    } catch (Throwable t) {
      Log.e("Playground", "Exception during channel invoke", t);
      result.error("Exception during channel invoke", t.getMessage(), null);
    }
  }
});

This small piece of code will try to find an instance method on MainActivity with a matching name and signature of void method(Object, MethodChannel.Result). If it succeeds, it will invoke that method by providing arguments from dart as the first argument and the result callback as the second.

Having the glue above will allow us to fix the broken playground code. Remember that in dart, we called the native code like this and expected a string in return.

// Main function called when playground is run
  void runPlayground() async {
    var testResult = await channel.invokeMethod("test");

    setState(() {
      log(testResult);
    });
  }

Having our glue, we can now define a test()method on MainActivity to automatically plug these pieces to each other.

public class MainActivity extends FlutterActivity {
  
  ...

  void test(Object args, MethodChannel.Result result) {
    result.success("YAY from Java!");
  }
}

Run your app and click the play button to see if it works.

iOS Native Plugin

Steps for iOS is quite similar to what we did in our MainActivity. Let’s start editing our AppDelegate.

First, we will create a channel with the same name we used on the dart side.

@implementation AppDelegate
{
    FlutterMethodChannel* channel;
}

- (BOOL)application:(UIApplication *)application
    didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
  [GeneratedPluginRegistrant registerWithRegistry:self];

  // Prepare channel
  FlutterViewController* controller = (FlutterViewController*)self.window.rootViewController;
  self->channel = [FlutterMethodChannel methodChannelWithName:@"playground" binaryMessenger:controller];
    
  __weak typeof(self) weakSelf = self;
  [self->channel setMethodCallHandler:^(FlutterMethodCall* call, FlutterResult result) {
      // TODO : invoke native method
  }];
    
  return [super application:application didFinishLaunchingWithOptions:launchOptions];
}

When dart code calls invokeMethod() on a channel, it specifies a method name. That name is accessed via call.method property. We can either compare that name in an if-else block or allow Objective-C reflection to do that for us automatically.

__weak typeof(self) weakSelf = self;
[self->channel setMethodCallHandler:^(FlutterMethodCall* call, FlutterResult result) {
    @try {
        // Find a method with the same name in activity
        SEL method = NSSelectorFromString([call.method stringByAppendingString:@":result:"]);
        
        // Call method if exists
        [weakSelf performSelector: method withObject:call.arguments withObject:result];
    } @catch (NSException *exception) {
        NSLog(exception.description);
        result([FlutterError errorWithCode:@"Exception"
                                   message:exception.description
                                   details:nil]);
    } @finally {
    }
}];

This small piece of code will try to find an instance function on AppDelegate with a matching name and signature of -(void) method:(id)args result:(FlutterResult)result. If it succeeds, it will invoke that function by providing arguments from dart as the first argument and the result callback as the second.

Having the glue above will allow us to fix the broken playground code. Remember that in dart, we called the native code like this and expected a string in return.

// Main function called when playground is run
  void runPlayground() async {
    var testResult = await channel.invokeMethod("test");

    setState(() {
      log(testResult);
    });
  }

Having our glue, we can now define a test:function on AppDelegate to automatically plug these pieces to each other.

- (void) test:(id)args result:(FlutterResult)result {
    result(@"YAY from Objective-C!");
}

@end

Run your app and click the play button to see if it works.

What now?

From now on, whenever you have a native functionality to bridge to your app, you can clone this project and test your idea from scratch. Remember that you can add native Android and iOS dependencies to your project as well.

  • In Android, edit android/app/build.gradle file.
  • In iOS, install CocoaPods if you haven’t already.
  • Then initiatialize the pods.

pod init

  • Then, edit ios/Podfile to add your dependencies.
  • Finally download them with the following command.

pod install


You can find the entire project here if you want to get started immediately.

P.S: If you are interested in a Swift version of this project,  please let us know. Most of the time, implementing the same behavior in Swift translates quite similarly from the Objective-C implementation. You can find  project creation docs for Swift oand Kotlin projects here.

Remember, sharing is caring!