Platform-Specific Code With Flutter Method Channel: Getting Started

Learn how to communicate with some platform-specific code with Flutter method channels and extend the functionality of the Flutter application. By Wilberforce Uwadiegwu.

Leave a rating/review
Download materials
Save for later
Share
You are currently viewing page 2 of 4 of this article. Click here to view the first page.

Reading Image Data From iOS

For better performance, read the image data on demand, i.e., when Flutter actually needs to display that particular image. To read an image, you need the ID along with the width and height of the image widget. Start by getting the PHAsset of the corresponding ID from fetchResult with a linear search and read the image data from the asset.

Write this function below getPhotos():

private func fetchImage(args: Dictionary<String, Any>?, result: @escaping FlutterResult) {
    // 1
    let id = args?["id"] as! String
    let width = args?["width"] as! Double
    let height = args?["height"] as! Double

    // 2
    self.fetchResult?.enumerateObjects { (asset: PHAsset, count: Int, stop: UnsafeMutablePointer<ObjCBool>) in
        if (asset.localIdentifier == id) {
            // 3
            stop.pointee = true
            // 4
            self.requestImage(width: width, height: height, asset: asset) { data in
                // 5
                result(data)
            }
            return
        }
    }

}

In this code, you:

  1. Get the method arguments.
  2. Get the matching PHAsset from fetchResult.
  3. Stop the search, since a match has been found.
  4. Read the image data from PHAsset. requestImage will be declared later.
  5. Return the image data to Flutter.

Both the method argument and result handler were passed directly to fetchImage() to avoid concurrency issues since Flutter will fire off image requests as the user scrolls across the grid of images.

Having retrieved PHAsset, the next step is to fetch the image data. So, write this function below fetchImage():

private func requestImage(width: Double, height: Double, asset: PHAsset, onComplete: @escaping (Data) -> Void) {
    let size = CGSize.init(width: width, height: height)
    let option = PHImageRequestOptions()
    option.isSynchronous = true
    PHImageManager
        .default()
        .requestImage(for: asset, targetSize: size, contentMode: .default, options: option) { image, _ in
            guard let image = image,
                  let data = image.jpegData(compressionQuality: 1.0) else { return }
            onComplete(data)
        }
}

The instructions above fetched the image data and then called the onComplete closure with the data.

The final step for this session is to add another case to the method handler, like so:

case "fetchImage":
    self?.fetchImage(args: call.arguments as? Dictionary<String, Any>, result: result)

Run the project with Xcode or your Flutter IDE, and you should notice that nothing has changed visually:

Screenshot of the starter project after implementing the swift end

That’s all for the iOS side. Good job!

Mr-Macaroni you're doing well meme

Setting up the Method Channel on Flutter

Start this session by going back to Android Studio or VSCode and opening constants.dart inside the common directory. Then, add this import statement above the MyStrings class:

import 'package:flutter/services.dart';

Next, declare the method channel below the MyStrings class:

const methodChannel = MethodChannel('com.raywenderlich.photos_keyboard');

Notice that the channel’s name is the same as the one you used earlier in Swift.

Head to home.dart in the widgets directory and write this function below onImageTap():

void getAllPhotos() async {
  // 1
  gridHeight = getKeyboardHeight();
  // 2
  final results = await methodChannel.invokeMethod<List>('getPhotos', 1000);
  if (results != null && results.isNotEmpty) {
    setState(() {
      images = results.cast<String>();
      // 3
      showGrid = images.isNotEmpty;
      // 4
      focus.unfocus();
    });
  }
}

Here’s what this code does:

  1. Gets the height of the soft keyboard. This height is used as the height of the images grid widget.
  2. Calls the method. The method name is getPhotos and the argument is 1000. Recall that earlier you used this size to decide the number of images to fetch in Swift. invokeMethod() was awaited on because it returns a Future. Since you expect a list of objects, you specify List as the type for invokeMethod().
  3. Shows the images grid only if the host platform returns images.
  4. Dismisses the soft keyboard.

To put a closure on this step, add the following logic to the empty togglePhotos() below build():

if (showGrid) {
  setState(() {
    showGrid = false;
    focus.requestFocus();
  });
} else {
  getAllPhotos();
}

Note that BottomContainer calls togglePhotos() when the user taps the gallery icon.

Building a Custom Image Provider in Flutter

Flutter supports loading images from various sources, none of which fit the current use case. So, how do you render the images? The answer to this question lies partly in the implementation of FileImage, an ImageProvider used for decoding images from filehandles. You’ll implement something similar, but instead of reading the bytes from a file, you’ll use the method channel to read the bytes from the host platform.

Start by creating adaptive_image.dart inside the common directory. Then, declare an AdaptiveImage class, which sublasses ImageProvider:

class AdaptiveImage extends ImageProvider<AdaptiveImage> {
  final String id;
  final double width;
  final double height;

  AdaptiveImage({required this.id, required this.width, required this.height});

  @override
  ImageStreamCompleter load(AdaptiveImage key, DecoderCallback decode) {
    // TODO: implement load
    throw UnimplementedError();
  }

  @override
  Future<AdaptiveImage> obtainKey(ImageConfiguration configuration) {
   return SynchronousFuture<AdaptiveImage>(this);
  }
}

This is the skeleton of this class, but here’s a breakdown:

  • load() uses the variables of the class to load the appropriate image from the host platform.
  • obtainKey generates a key object. The key object describes the properties of the image to load. Since there’s no asynchronous task being done in this function, SynchronousFuture is simply returned. SynchronousFuture is a Future that completes immmediately.

Now, add the following import statements:

import 'dart:typed_data';
import 'dart:ui';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'constants.dart';

Subsequently, declare a function below obtainKey(), like so:

Future<Codec> _loadAsync(AdaptiveImage key, DecoderCallback decode) async {
  assert(key == this);

  // 1
  final bytes = await methodChannel.invokeMethod<Uint8List>(
      'fetchImage', {'id': id, 'width': width, 'height': height});

  // 2
  if (bytes == null || bytes.lengthInBytes == 0) {
    PaintingBinding.instance!.imageCache!.evict(key);
    throw StateError("Image for $id couldn't be loaded");
  }
  return decode(bytes);
}

In this code, you:

  1. Request the image bytes from the host platform.
  2. Remove the image key from cache if the request fails.

Since you’re using the class as the key for the image, you need to implement a proper equality relation. To do this, override both the equality operator and hashCode. Write this code below _loadAsync():

@override
bool operator ==(Object other) =>
    identical(this, other) ||
    other is AdaptiveImage &&
        runtimeType == other.runtimeType &&
        id == other.id &&
        width == other.width &&
        height == other.height;

@override
int get hashCode => id.hashCode ^ width.hashCode ^ height.hashCode;

This means that given two AdaptiveImage objects, they’re both equal if they’re the same object and their id, width and height properties are equal.

Although not required, you can also override toString() for debugging purpose. So, add this function below the hashCode override:

@override
String toString() {
  return '${objectRuntimeType(this, 'AdaptiveImage')}('
      '"$id", width: $width, height: $height)';
}

The final step is to implement load(). Just like ImageFile, you’ll return an instance of MultiFrameImageStreamCompleter, like so:

return MultiFrameImageStreamCompleter(
    codec: _loadAsync(key, decode),
    scale: 1.0,
    debugLabel: id,
    informationCollector: () sync* {
      yield ErrorDescription('Id: $id');
    });

MultiFrameImageStreamCompleter is responsible for converting the bytes to a format that the Image widget can display. You can read more about it in Flutter’s documentation.

Run the project, and you shouldn’t see any changes yet.

Screenshot of the starter project after implementing the Swift end

Congrats! You’ve successfully implemented a custom image provider.

Jennifer Lopez clapping meme