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 3 of 4 of this article. Click here to view the first page.

Rendering Images From the Host Device

Having implemented a custom ImageProvider, the next step is using it to load the images.

Kick off this section by opening images_grid.dart in the widgets directory. The build tree currently consists of a horizontal scroll widget containing a column of icons. You’ll add a SliverGrid to this horizontal scroll widget. But first, you need to determine the logical pixel of each of the images.

Add this declaration just above the return statement in build():

final imageSize = MediaQuery.of(context).size.width * 0.6;

This means the width and height of each of the images will be 60% of the device width.

Next, import AdaptiveImage into images_grid.dart, like so:

import '../common/adaptive_image.dart';

Below the ImagesGridWidget class, declare another class named _ImageWidget, like so:

class _ImageWidget extends StatelessWidget {
  final String id;
  final VoidCallback onTap;
  final double size;

  const _ImageWidget({
    Key? key,
    required this.onTap,
    required this.id,
    required this.size,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
  }
}

This class will contain the build instructions for individual image widgets. Add the following code inside its build():

return Stack(
  children: [
    Positioned.fill(
      child: Image(
        key: ValueKey(id),
        fit: BoxFit.cover,
        image: AdaptiveImage(
          id: id,
          width: size,
          height: size,
        ),
      ),
    ),
    Positioned.fill(
      child: Material(
        color: Colors.transparent,
        child: InkWell(onTap: onTap),
      ),
    )
  ],
);

The build instructions above will display the image using the AdaptiveImage you wrote earlier.

Next, replace the TODO text in the build() of ImagesGridWidget with this:

SliverGrid.count(
  crossAxisCount: 2,
  crossAxisSpacing: 5,
  mainAxisSpacing: 5,
  children: images.map((e) {
    return _ImageWidget(
      onTap: () => onImageTap(e),
      id: e,
      size: imageSize,
    );
  }).toList(),
),

The instructions above should be familiar; you’re basically mapping the images to _ImageWidget.

Now, run the app, tap the gallery icon and you should see something similar to this:

Top half of screen with grayed-out fields, bottom half with photos of nature

Rendering Selected Images

The final stage for this section allows the user to add and remove images in the input area.

Open images_list.dart in the widgets directory. Then, add this import statement:

import '../common/adaptive_image.dart';

You’ll pass a ListView as the child of the SizedBox in build() later, but first, declare the class for the single image widget below ImagesListWidget, like so:

class _ImageWidget extends StatelessWidget {
  final String id;
  final VoidCallback onRemoved;
  final double size;

  const _ImageWidget({
    Key? key,
    required this.onRemoved,
    required this.id,
    required this.size,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    final isIos = Theme.of(context).platform == TargetPlatform.iOS;
    final imageSize = size - 15;
    return Padding(
      padding: const EdgeInsets.only(left: 5, right: 10),
      child: SizedBox(
        height: size,
        width: size,
        child: Stack(
          clipBehavior: Clip.none,
          children: [
            
          ],
        ),
      ),
    );
  }
}

The Stack widget will have two widgets: the image widget and the cancel icon on top of it. So, pass the following widgets as the children to the Stack widget in build() of _ImageWidget:

Positioned(
  top: 15,
  child: ClipRRect(
    borderRadius: BorderRadius.circular(8),
    child: Image(
      width: imageSize,
      height: imageSize,
      fit: BoxFit.cover,
      image: AdaptiveImage(
        id: id,
        width: imageSize,
        height: imageSize,
      ),
    ),
  ),
),
Positioned(
  top: -10,
  right: -10,
  child: IconButton(
    onPressed: onRemoved,
    icon: Icon(
      isIos ? CupertinoIcons.multiply_circle_fill : Icons.cancel,
    ),
  ),
)

You’re using negative offsets for the top and right arguments of the cancel icon to ensure it’s positioned at the edge of the item widget.

Next, supply the child argument to the SizedBox in the build() of ImagesListWidget:

child: ListView.builder(
  controller: controller,
  scrollDirection: Axis.horizontal,
  itemCount: images.length,
  padding: const EdgeInsets.symmetric(horizontal: 10),
  itemBuilder: (c, i) {
    final id = images.elementAt(i);
    return _ImageWidget(
      key: ValueKey(id),
      onRemoved: () => onRemoved(id),
      id: id,
      size: itemSize,
    );
  },
),

Subsequently, open home.dart and update both onImageRemoved() and onImageTap() to:

void onImageRemoved(String id) {
  setState(() => selectedImages.remove(id));
}

void onImageTap(String id) {
  setState(() => selectedImages.add(id));
  WidgetsBinding.instance?.addPostFrameCallback((_) {
    final pos = selectedImagesController.position.maxScrollExtent;
    selectedImagesController.jumpTo(pos);
  });
}

One of these functions is called when the state of selectedImages needs to mutate. After updating selectedImages, onImageTap() scrolls the ListView to the end to ensure the just-added image is visible.

Now, run on iOS, and you should have a similar experience:

Photos being selected in the app

Setting up Method Channel on Android with Kotlin

In this section, you’ll replicate the Method Channel setup flow you already did on iOS.

Start by opening the android directory in the starter project with Android Studio. Then, open MainActivity.kt, and add the following fields:

class MainActivity : FlutterFragmentActivity() {
    private var methodResult: MethodChannel.Result? = null
    private var queryLimit: Int = 0
}

Also, add these import statements to the import section:

import android.Manifest
import android.content.ContentResolver
import android.content.ContentUris
import android.content.pm.PackageManager
import android.database.Cursor
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.provider.MediaStore
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.ContextCompat
import androidx.lifecycle.lifecycleScope
import com.bumptech.glide.Glide
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch

Next, override configureFlutterEngine(), set up the Method Channel and listen for method calls. Write this function below the fields you just declared:

override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
    super.configureFlutterEngine(flutterEngine)
    val messenger = flutterEngine.dartExecutor.binaryMessenger
    MethodChannel(messenger, "com.raywenderlich.photos_keyboard")
        .setMethodCallHandler { call, result ->
            when (call.method) {
                "getPhotos" -> {
                    methodResult = result
                    queryLimit = call.arguments()
                    getPhotos()
                }
                "fetchImage" -> fetchImage(call.arguments(), result)
                else -> result.notImplemented()
            }
        }
}

Finally, add these dummy functions below the override above:

private fun getPhotos() {
    TODO("Not yet implemented")
}

private fun fetchImage(args: Map<String, Any>, result: MethodChannel.Result) {
    TODO("Not yet implemented")
}

Understanding Android’s Media API

Access to local media on Android is policed by the MediaStore API. Android stores data pertaining to each kind of media — image, video, audio, etc. — in an SQLite database, and you make SQL-like queries to retrieve the data. So, throughout this section, you’ll work with the MediaStore API to query the images on the device and read the image data.

Requesting User Permissions on Android

Android has two major sets of permissions: runtime permissions and install-time permissions. The latter set, such as internet access, is implicitly granted to your app at install time. The former controls access to more sensitive data, and the user needs to grant it explicitly. Reading the images from an Android device is an example of such permission.

Open AndroidManifest.xml in the manifests directory, and add this declaration above the internet permission declaration:

 <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>

With this, you’re declaring that your app will read the external storage sometime in its lifecycle.

Back in MainActivity, declare an activity result launcher field below the previous functions:

private val permissionLauncher =
    registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
        if (granted) {
            getPhotos()
        } else {
            methodResult?.error("0", "Permission denied", "")
        }
    }

The lambda function is called after the permission dialog closes, and granted is the result of the permission.

Next, use this permissionLauncher to request permission. So, declare a function above this field:

private fun hasStoragePermission(): Boolean {
    // 1
    val permission = Manifest.permission.READ_EXTERNAL_STORAGE
    // 2
    val state = ContextCompat.checkSelfPermission(this, permission)
    if (state == PackageManager.PERMISSION_GRANTED) return true
    
    // 3
    permissionLauncher.launch(permission)
    return false
}

In this code:

  1. This is the identifier for the permission you want to request.
  2. This checks if your app already has the permission and returns true if so.
  3. If your app doesn’t have the permission, it requests permission.