27 How to Account for Platform Specific Implementations in Dart for Android I Os Ii

27 How to Account for Platform-Specific Implementations in Dart for Android-iOS II #

Hello, I’m Chen Hang.

In the previous article, I introduced the method channel, which is a lightweight solution in Flutter for invoking native Android and iOS code. Using the method channel, we can provide the capabilities of native code to Dart through an interface.

In this way, when making a method call, the Flutter application will transmit the request data through a method channel specified by a unique identifier to the native code host, similar to making a network asynchronous call. After the native code is processed, the response result will be passed back to Flutter through the method channel, thus achieving the interaction between Dart code and native Android and iOS code. This is not much different from calling a native Dart asynchronous API.

Through the method channel, we can quickly handle the low-level capabilities provided by the native operating system, as well as some mature solutions in existing native development, in a wrapped form on the Dart layer to solve the problem of reusing native code in Flutter. Then, we can use the rich controls provided by Flutter itself to render the UI.

With low-level capabilities + application-level rendering, it seems that we have handled all the content of building a complex app. But, is it really so?

What do you need to build a complex app? #

Before reaching a conclusion, let’s use the four-quadrant analysis method to decompose capabilities and rendering into four dimensions and analyze what is needed to build a complex app.

Image

Figure 1 Four-quadrant analysis method

After analysis, we finally discovered that building an app requires covering so many knowledge points. Flutter and method channels can only handle application layer rendering, application layer capabilities, and low-level capabilities. It is not practical to develop a new set of low-level rendering, such as browsers, cameras, maps, and native custom views, on Flutter.

In this case, using hybrid views seems to be a good choice. We can reserve a blank area in the Flutter widget tree and embed a native view that matches the blank area completely in the Flutter canvas (i.e. FlutterView and FlutterViewController) to achieve the desired visual effect.

However, adopting this approach is extremely inelegant because the embedded native view is not in the rendering hierarchy of Flutter. A large amount of adaptation work needs to be done on both the Flutter side and the native side to achieve a normal user interaction experience.

Fortunately, Flutter provides the concept of platform views. It provides a method that allows developers to embed native system (Android and iOS) views inside Flutter and add them to the Flutter rendering tree to achieve consistent interaction experience with Flutter.

With platform views, we can wrap a native control as a Flutter control and embed it into a Flutter page, just like using an ordinary widget.

Next, I will explain in detail how to use platform views.

Platform View #

If method channels solve the problem of reusing native ability logic, then platform views solve the problem of reusing native views. Flutter provides a lightweight method that allows us to create native (Android and iOS) views and, after wrapping them with some simple Dart layer interfaces, insert them into the widget tree to achieve a mixture of native and Flutter views.

The process of using a platform view is similar to that of a method channel:

  • First, as the client-side Flutter, we pass a view identifier to the Flutter wrapper class for the native view (UIKitView for iOS and AndroidView for Android) to initiate a request to create the native view.
  • Then, the native code delegates the creation of the corresponding native view to the platform view factory (PlatformViewFactory).
  • Finally, the native code associates the view identifier with the platform view factory, so that the view creation request initiated by Flutter can find the corresponding view creation factory.

With that, we can use native views just like using widgets. The entire process is shown in the following diagram:

图2 平台视图示例

Next, I will demonstrate how to use platform views by using a specific example, which is embedding a red native view into Flutter. This section includes two main parts:

  • How does Flutter implement the interface invocation for the native view as the caller?
  • How to implement the interface in the native (Android and iOS) systems?

Next, I will explain these two questions separately.

How does Flutter implement the interface invocation for the native view? #

In the following code, within the SampleView, we use the wrapper classes for native Android and iOS views, AndroidView and UiKitView respectively, and pass in a unique identifier to establish a connection with the native view:

class SampleView extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    // Use AndroidView for Android platform, pass the unique identifier 'sampleView'
    if (defaultTargetPlatform == TargetPlatform.android) {
      return AndroidView(viewType: 'sampleView');
    } else {
      // Use UIkitView for iOS platform, pass the unique identifier 'sampleView'
      return UiKitView(viewType: 'sampleView');
    }
  }
}

As you can see, the usage of platform views in Flutter is relatively simple and does not differ significantly from regular widgets. For more information on how to use regular widgets, you can refer to the content in the 12th and 13th articles for review.

Now that the caller’s implementation is done, next we need to complete the encapsulation of the view creation in the native code and establish the relevant binding relationships. Similarly, since we need to adapt to both Android and iOS platforms, we need to complete the corresponding interface implementation on both systems.

How to implement the interface in the native systems? #

First, let’s look at the implementation on the Android side. In the following code, we create the platform view factory and the wrapper class for the native view, and associate them through the create method of the view factory:

// View factory class
class SampleViewFactory extends PlatformViewFactory {
    private final BinaryMessenger messenger;

    // Initialization method
    public SampleViewFactory(BinaryMessenger msger) {
        super(StandardMessageCodec.INSTANCE);
        messenger = msger;
    }

    // Create the wrapper class for the native view and establish the association
    @Override
    public PlatformView create(Context context, int id, Object obj) {
        return new SimpleViewControl(context, id, messenger);
    }
}

// Wrapper class for the native view
class SimpleViewControl implements PlatformView {
    private final View view; // Cache the native view

    // Initialization method, create the view in advance
    public SimpleViewControl(Context context, int id, BinaryMessenger messenger) {
        view = new View(context);
        view.setBackgroundColor(Color.rgb(255, 0, 0));
    }

    // Return the native view
    @Override
    public View getView() {
        return view;
    }

    // Callback for disposing the native view
    @Override
    public void dispose() {
    }
}

After associating the wrapper class for the native view with the platform view factory, we need to bind the call from the Flutter side to the view factory. Similar to the method channel explained in the previous article, we still need to perform the binding operation in MainActivity:

protected void onCreate(Bundle savedInstanceState) {
  ...
  Registrar registrar = registrarFor("samples.chenhang/native_views"); // Generate the registrar class
  SampleViewFactory playerViewFactory = new SampleViewFactory(registrar.messenger()); // Generate the view factory

  registrar.platformViewRegistry().registerViewFactory("sampleView", playerViewFactory); // Register the view factory
}

After completing the binding, the platform view calls the corresponding Android part.

Next, let’s take a look at the implementation on the iOS side.

Similar to Android, we also need to create a platform view factory and a native view wrapper class separately, and associate them together through the create method of the view factory:

// Platform View Factory @interface SampleViewFactory : NSObject

  • (instancetype)initWithMessenger:(NSObject*)messenger; @end

@implementation SampleViewFactory{ NSObject*_messenger; }

  • (instancetype)initWithMessenger:(NSObject *)messenger{ self = [super init]; if (self) { _messenger = messenger; } return self; }

-(NSObject *)createArgsCodec{ return [FlutterStandardMessageCodec sharedInstance]; }

//Create Native View Wrapper Instance -(NSObject *)createWithFrame:(CGRect)frame viewIdentifier:(int64_t)viewId arguments:(id)args{ SampleViewControl *activity = [[SampleViewControl alloc] initWithWithFrame:frame viewIdentifier:viewId arguments:args binaryMessenger:_messenger]; return activity; } @end

// Platform View Wrapper Class @interface SampleViewControl : NSObject

  • (instancetype)initWithWithFrame:(CGRect)frame viewIdentifier:(int64_t)viewId arguments:(id _Nullable)args binaryMessenger:(NSObject*)messenger; @end

@implementation SampleViewControl{ UIView * _tempalteView; }

// Create Native View

  • (instancetype)initWithWithFrame:(CGRect)frame viewIdentifier:(int64_t)viewId arguments:(id)args binaryMessenger:(NSObject *)messenger{ if ([super init]) { _tempalteView = [[UIView alloc] init]; _tempalteView.backgroundColor = [UIColor redColor]; } return self; }

-(UIView *)view{ return _tempalteView; }

@end

Then, we also need to associate the creation of the native view with the Flutter side call to find the implementation of the native view:

  • (BOOL)application:(UIApplication )application didFinishLaunchingWithOptions:(NSDictionary )launchOptions { NSObject registrar = [self registrarForPlugin:@“samples.chenhang/native_views”];// Generate registrar SampleViewFactory viewFactory = [[SampleViewFactory alloc] initWithMessenger:registrar.messenger];// Generate view factory [registrar registerViewFactory:viewFactory withId:@“sampleView”];// Register view factory … }

Note that on the iOS platform, Flutter-embedded UIKitView is currently in technical preview, so we need to add a configuration item in the Info.plist file to set the switch of embedding native views to true in order to enable this hidden feature:

After the above encapsulation and binding, the platform view functionality of both Android and iOS platforms has been implemented. Next, we can embed native views in the Flutter application just like using normal widgets:

Scaffold( backgroundColor: Colors.yellowAccent, body: Container(width: 200, height:200, child: SampleView(controller: controller) ));

As shown below, we embedded a red native view in the Flutter applications on both iOS and Android platforms:

Figure 3. Example of embedding native views

In the example above, we encapsulated the native view in a StatelessWidget, which is suitable for static display scenarios. If we need to dynamically adjust the style of the native view at runtime, how should we handle it?

How to dynamically adjust the style of native views during runtime in Flutter? #

Compared to Flutter widgets that are based on a declarative approach, where every change is driven by data and the view is destroyed and rebuilt, native views are based on an imperative approach, which allows for precise control over the visual style. Therefore, it is possible to dynamically adjust the visual style of native views during runtime by exposing methods through the method channel in the native view’s wrapper class. This way, Flutter can also have the ability to dynamically adjust the view’s visual style.

Next, I will demonstrate how to dynamically adjust the background color of an embedded native view during runtime using a concrete example.

In this example, we will utilize the onPlatformViewCreated property, which is an initialization property of native views. After the native view is created, it will notify Flutter of the view’s id through a callback. We can register a method channel at this point, allowing subsequent view modification requests to be passed through this channel to the native view.

Since we directly hold the instance of the native view on the underlying layer, theoretically, we can provide view modification methods directly on the Flutter wrapper class, regardless of whether it is a StatelessWidget or StatefulWidget. However, to adhere to Flutter’s widget design philosophy, we decided to separate the view presentation and view control. Specifically, we encapsulate the native view as a StatefulWidget dedicated to presentation and use its controller initialization parameter to modify the native view’s visual appearance during runtime. The code sample is as follows:

// Native view controller
class NativeViewController {
  MethodChannel _channel;

  // Callback after the native view is created, generates a unique method channel with the view id
  void onCreate(int id) {
    _channel = MethodChannel('samples.chenhang/native_views_$id');
  }

  // Call the method of the native view to change the background color
  Future<void> changeBackgroundColor() async {
    return _channel.invokeMethod('changeBackgroundColor');
  }
}

// Flutter wrapper class for the native view, extends StatefulWidget
class SampleView extends StatefulWidget {
  const SampleView({
    Key key,
    this.controller,
  }) : super(key: key);

  // Holds the view controller
  final NativeViewController controller;

  @override
  State<StatefulWidget> createState() => _SampleViewState();
}

class _SampleViewState extends State<SampleView> {
  // Determine which platform-specific view to return based on the platform
  @override
  Widget build(BuildContext context) {
    if (defaultTargetPlatform == TargetPlatform.android) {
      return AndroidView(
        viewType: 'sampleView',
        // Callback after the native view is created
        onPlatformViewCreated: _onPlatformViewCreated,
      );
    } else {
      return UiKitView(
        viewType: 'sampleView',
        // Callback after the native view is created
        onPlatformViewCreated: _onPlatformViewCreated,
      );
    }
  }

  // Callback after the native view is created, call the onCreate method of the controller and pass in the view id
  void _onPlatformViewCreated(int id) {
    if (widget.controller == null) {
      return;
    }
    widget.controller.onCreate(id);
  }
}

The calling code in Flutter is in place. Now, let’s take a look at the Android and iOS implementations respectively.

The overall structure of the program remains the same as before, but during native view initialization, we need to register the method channel and handle the related events. When responding to method call messages, we need to check the method name and if it matches exactly, modify the view’s background and return success. Otherwise, we return an exception.

Here is the implementation of the Android interface:

public class SimpleViewControl implements PlatformView, MethodCallHandler {
    private final MethodChannel methodChannel;
    ...
    public SimpleViewControl(Context context, int id, BinaryMessenger messenger) {
        ...
        // Register the method channel with the view id
        methodChannel = new MethodChannel(messenger, "samples.chenhang/native_views_" + id);
        // Set the method channel's handler
        methodChannel.setMethodCallHandler(this);
    }
    // Handle method call messages
    @Override
    public void onMethodCall(MethodCall methodCall, MethodChannel.Result result) {
        // If the method name matches exactly
        if (methodCall.method.equals("changeBackgroundColor")) {
            // Modify the view's background color and return success
            view.setBackgroundColor(Color.rgb(0, 0, 255));
            result.success(0);
        } else {
            // The caller made an unsupported API call
            result.notImplemented();
        }
    }
    ...
}

And here is the implementation of the iOS interface:

@implementation SampleViewControl {
    ...
    FlutterMethodChannel* _channel;
}

- (instancetype)initWithWithFrame:(CGRect)frame viewIdentifier:(int64_t)viewId arguments:(id)args binaryMessenger:(NSObject<FlutterBinaryMessenger>*)messenger {
    if ([super init]) {
        ...
        // Create the method channel with the view id
        _channel = [FlutterMethodChannel methodChannelWithName:[NSString stringWithFormat:@"samples.chenhang/native_views_%lld", viewId] binaryMessenger:messenger];
        // Set the method channel's handler
        __weak __typeof__(self) weakSelf = self;
        [_channel setMethodCallHandler:^(FlutterMethodCall* call, FlutterResult result) {
            [weakSelf onMethodCall:call result:result];
        }];
    }
    return self;
}

// Respond to method call messages
- (void)onMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result {
    // If the method name matches exactly
    if ([[call method] isEqualToString:@"changeBackgroundColor"]) {
        // Modify the view's background color and return success
        _templcateView.backgroundColor = [UIColor blueColor];
        result(@0);
    } else {
        // The caller made an unsupported API call
        result(FlutterMethodNotImplemented);
    }
}
...
@end

With the method channel registration and the exposed changeBackgroundColor interface, the functionality to modify the native view’s background color has been implemented on both the Android and iOS sides. We can now modify the visual appearance of the native view during runtime in the Flutter application:

class DefaultState extends State<DefaultPage> {
  NativeViewController controller;

  @override
  void initState() {
    controller = NativeViewController(); // Initialize the native view controller
    super.initState();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      ...
      // Embedding the native view
      body: Container(
        width: 200,
        height: 200,
        child: SampleView(controller: controller),
      ),
      // Set the click behavior: change the view's color
      floatingActionButton: FloatingActionButton(
        onPressed: () => controller.changeBackgroundColor(),
      ),
    );
  }
}

Run the code and the effect is as shown below:

Dynamic modification of native view style

Figure 4: Dynamic modification of native view style

Summary #

Alright, that’s all for today’s sharing. Let’s summarize the main points of today’s content.

Platform views solve the problem of reusing native rendering capabilities, allowing Flutter to assemble native views into a Flutter widget through lightweight code encapsulation.

Flutter provides two concepts: platform view factory and view identifier. Therefore, view creation requests initiated in the Dart layer can directly find the corresponding view creation factory through identifiers, thereby achieving the integration and reuse of native views and Flutter views. For the need to dynamically call native view interfaces at runtime, we can register method channels in the encapsulation class of native views to achieve precise control over the display effects of native views.

It is worth noting that due to the completely different rendering methods between Flutter and native, there will be a significant performance overhead when converting different rendering data. If multiple native controls are instantiated on the same interface, it will have a significant impact on performance. Therefore, we should avoid using embedded platform views when Flutter controls can achieve the same effect.

By doing so, on the one hand, we need to write a large amount of adaptation and bridging code separately on the Android and iOS sides, which goes against the original intention of cross-platform technology and also increases future maintenance costs. On the other hand, except for special cases involving underlying solutions such as maps, WebView, and cameras, most UI effects achievable with native code can be completely implemented using Flutter.

I have packaged the knowledge points discussed in today’s sharing on GitHub. You can download it and run it multiple times to deepen your understanding.

Thought-provoking question #

Finally, I will leave you with a thought-provoking question.

On the basis of the code for dynamically adjusting the styles of native views, please add a color parameter to achieve the requirement of dynamically changing the color of native views.

Feel free to leave a comment in the comment section and share your thoughts with me. I will be waiting for you in the next article! Thank you for listening, and also feel free to share this article with more friends to read together.