Integrating Native UI in Flutter with PlatformView

Patrick Ngo
7 min readMay 25, 2020

--

Context

I’ve recently been exploring Flutter and we wanted to see how difficult it is to integrate existing native view in Flutter and be able to communicate back and forth from Flutter to this native view.

Let’s assume that we have a native view called called MagicView (which inherits from UIView on iOS and View on Android) and it has two very simple methods.

iOS:

class MagicView: UIView {
func sendFromNative(text: String)
func receiveFromFlutter(text: String)
}

Android:

class MagicView: View {
fun sendFromNative(text: String)
fun receiveFromFlutter(text: String)
}

The sendFromNative method sends a string from the native side, to be used on the Flutter side. The receiveFromFlutter method receives a string from Flutter, to be used on the native side.

Concepts

To understand how to use native views in Flutter, we need to get a few concepts out of the way first.

  • Registry: Keeps track of active plugins
  • Registrar: Registration context for one single plugin
  • Plugin: Special type of Dart package that also contains platform-specific implementations in native iOS/Android code
  • PlatformViewFactory: Part of plugin that provides native UI
  • PlatformView: Mechanism to embed a native iOS view (UiKitView) or native or native Android view (AndroidView) in the Widget hierarchy
  • Method Channel: Mechanism to pass messages back and forth between Flutter and native iOS/Android. Every channel needs a Binary Messenger and a Codec
  • Binary Messenger: Asynchronous message passing with binary messages
  • Codec: Mechanism for message encoding and decoding

Native side

Plugin

We first need to create a Plugin. Plugins are small packages used by Flutter whenever native iOS/Android code is needed. In our case, our plugin needs a PlatformViewFactory to display UI. Our MagicViewPlugin’s sole responsibility will be to create this PlatformViewFactory and register it to the Registrar. Each registrar also has an associated BinaryMessenger, is passed to the initialisation of our PlatformViewFactory.

iOS:

// MagicViewPlugin.swiftpublic class MagicViewPlugin {
class func register(with registrar: FlutterPluginRegistrar) {
let viewFactory = MagicViewFactory(messenger: registrar.messenger())
registrar.register(viewFactory, withId: "MagicPlatformView")
}
}

Android:

// MagicViewPlugin.ktobject MagicViewPlugin {
fun registerWith(registrar: Registrar) {
val viewFactory = MagicViewFactory(registrar.messenger();
registrar.platformViewRegistry()
.registerViewFactory("MagicPlatformView", viewFactory))
}
}

Registering the Plugin

To use the MagicViewPlugin, we need to register it on app start using a Registrar.

iOS:

// AppDelegate.swift@objc class AppDelegate: FlutterAppDelegate {
override func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplicationLaunchOptionsKey: Any]?) -> Bool {
GeneratedPluginRegistrant.register(with: self)
MagicViewPlugin.register(with: registrar(forPlugin: "Magic"))
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
}

Android:

// MainActivity.ktclass MainActivity: FlutterActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
GeneratedPluginRegistrant.registerWith(this)
MagicViewPlugin.registerWith(this.registrarFor("Magic"))
}
}

The Registrar is obtained from from the FlutterAppDelegate for iOS and FlutterActivity on Android. A unique key needs to be provided to obtain the Registrar for a particular plugin.

PlatformViewFactory

To create our MagicViewFactory, we need a BinaryMessenger and a Codec.

iOS:

// MagicViewFactory.swiftpublic class MagicViewFactory: NSObject, FlutterPlatformViewFactory {
init(messenger: FlutterBinaryMessenger) {
self.messenger = messenger
}
public func create(withFrame frame: CGRect,
viewIdentifier viewId: Int64,
arguments args: Any?) -> FlutterPlatformView {
return MagicViewContainer(messenger: messenger,
frame: frame, viewId: viewId,
args: args)
}
public func createArgsCodec() -> FlutterMessageCodec & NSObjectProtocol {
return FlutterStandardMessageCodec.sharedInstance()
}
}

Android:

// MagicViewFactory.ktclass MagicViewFactory(private val messenger: BinaryMessenger) : 
PlatformViewFactory(StandardMessageCodec.INSTANCE) {
override fun create(context: Context,
id: Int,
args: Any?): PlatformView {
return MagicViewContainer(context,
messenger,
id,
args)
}
}

The Binary Messenger is injected in from our Plugin class. In both cases, the StandardMessageCodec is used. The id of the view is automatically provided and the args are passed in from the Flutter side when creating the view.

PlatformView

The MagicPlatformView is essentially a wrapper class for our native view, in this case MagicView. In order for Flutter to know to create which native view to create, we return the magicView inside the view() / getView() method.

iOS

// MagicPlatformView.swiftpublic class MagicPlatformView: NSObject, FlutterPlatformView {  let viewId: Int64
let magicView: MagicView
let messenger: FlutterBinaryMessenger
let channel: FlutterMethodChannel
init(messenger: FlutterBinaryMessenger,
frame: CGRect,
viewId: Int64,
args: Any?) {
self.messenger = messenger
self.viewId = viewId
self.magicView = MagicView()

let channel = FlutterMethodChannel(name: "MagicView/\(Id)",
binaryMessenger: messenger)
channel.setMethodCallHandler({ (call: FlutterMethodCall, result: FlutterResult) -> Void in
switch call.method {
case "receiveFromFlutter":
guard let args = call.arguments as? [String: Any],
let text = args["text"] as? String, else {
result(FlutterError(code: "-1", message: "Error"))
return
}
self.magicView.receiveFromFlutter(text)
result("receiveFromFlutter success")
default:
result(FlutterMethodNotImplemented)
}
})
}
public func sendFromNative(_ text: String) {
channel.invokeMethod("sendFromNative", arguments: text)
}
public func view() -> UIView {
return magicView
}
}

Android:

// MagicPlatformView.ktclass MagicPlatformView internal constructor(private val context: Context?, messenger: BinaryMessenger?, id: Int, args: Any?) : PlatformView, MethodCallHandler {   private val magicView: MagicView
private val methodChannel: MethodChannel
init {
mapView = MagicView(context)
methodChannel = MethodChannel(messenger, "MagicView/$id")
methodChannel.setMethodCallHandler(this)
}
override fun onMethodCall(methodCall: MethodCall,
result: MethodChannel.Result) {
when (methodCall.method) {
"receiveFromFlutter" -> {
val text = methodCall.argument<String>("text")
if (text != null ) {
magicView.receiveFromFlutter(text)
result.success("receiveFromFlutter success")
} else {
result.error("-1", "Error")
}
}
else -> result.notImplemented()
}
}
private fun sendFromNative(text: String) {
methodChannel.invokeMethod("sendFromNative", text)
}
override fun getView(): View {
return magicView
}
}

The MethodChannel needs to be created using the injected BinaryMessenger. We then need to set the MethodCallHandler to receive method calls from the Flutter side. To send messages to the flutter side, we simply call methodChannel.invokeMethod(methodName, arguments).

Method Channel

In flutter terms, we refer to the Flutter app as the client and containing iOS or Android native app as the host. All communications between the client and the host are handled by the MethodChannel.

Codec

As mentioned previously, the Codec is a mechanism for message encoding and decoding. For this use case, we use the StandardMessageCodec. It can handle the following data types:

Diagram (Native)

Here’s what we have so far on the native side

Flutter Side

Widget

In order to create a native MagicPlatformView on Flutter side, we need to use the AndroidView or UiKitView widgets, respectively. We can then wrap this returned widget inside a MagicView widget.

// magic_view.darttypedef void MagicViewCreatedCallback(MagicViewController controller);class MagicView extends StatelessWidget {
static const StandardMessageCodec _decoder = StandardMessageCodec();
MagicView({
Key key,
}) : super(key: key);
@override
Widget build(BuildContext context) {
final Map<String, String> args = {"someInit": "initData"};
if (defaultTargetPlatform == TargetPlatform.android) {
return AndroidView(
viewType: 'MagicPlatformView',
onPlatformViewCreated: _onPlatformViewCreated,
creationParams: args,
creationParamsCodec: _decoder);
}
return UiKitView(
viewType: 'MagicPlatformView',
onPlatformViewCreated: _onPlatformViewCreated,
creationParams: args,
creationParamsCodec: _decoder);
}
void _onPlatformViewCreated(int id) {
if (onMagicViewCreated == null) {
return;
}
onMagicViewCreated(MagicViewController(id));
}
}

The viewType matches the name of the PlatformViewFactory key we use on native side. The creationParams is used to pass in initialisation parameters. the creationParamsCodec needs to match the one used on native side as well.

Controller

Since the creation of a PlatformView is asynchronous, onPlatformViewCreated is used to inform us once the PlatformView has been properly created. From there, we create a Controller to handle the communication back and forth between the native view and Flutter.

// magic_view_controller.dartclass MagicViewController {
MethodChannel _channel;
MagicViewController(int id) {
_channel = new MethodChannel('MagicView/$id');
_channel.setMethodCallHandler(_handleMethod);
}
Future<dynamic> _handleMethod(MethodCall call) async {
switch (call.method) {
case 'sendFromNative':
String text = call.arguments as String;
return new Future.value("Text from native: $text");
}
}
Future<void> receiveFromFlutter(String text) async {
try {
final String result = await _channel.invokeMethod('receiveFromFlutter', {"text": text});
print("Result from native: $result");
} on PlatformException catch (e) {
print("Error from native: $e.message");
}
}
}

On Flutter side, the MethodChannel is created inside the Controller. We define the MethodCallHandler to handle all the methods called from native to flutter. We also define all the methods to be sent from the controller to the native view.

Diagram (Flutter)

Here’s what we have on the Flutter side:

Using Widget and Controller

Let’s assume we have a simple Flutter screen with the main body as a MagicView widget. We also have floatingActionButton from which we want to send a string to the native side on tap.

// magic_screen.dartclass MagicScreen extends StatefulWidget {
@override
_MagicScreenState createState() => _MagicScreenState();
}
class _MagicScreenState extends State<MagicScreen> {
MagicViewController _magicViewController;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text("Magic App"),
),
body: Stack(
children: <Widget>[
MagicView(
onMagicViewCreated: (MagicViewController controller) {
setState(() {
_magicViewController = controller;
});
},
),
],
),
floatingActionButton: IconButton(
onPressed: () {
_magicViewController.receiveFromFlutter('hello');
},
),
);
}
}

As previously mentioned, the underlying native view inside the MagicView widget is created asynchronously. We therefore wait until the view is created with onMagicViewCreated, and then keep track of the created MagicViewController inside the screen’s flutter state.

On pressing the button, we can then imperatively call the methods we want on the _magicViewController.

In Short

Here’s a diagram of the overall boilerplate components needed to get it running:

--

--

Patrick Ngo

Mobile engineer mostly on iOS but sometimes tinkering with cross-platform such as React Native and Flutter