Platform-Specific Features with Flutter

Introduction

Providing a smooth and intuitive user experience (UX) often means using platform-specific features to match the unique hardware, software, and user preferences of each platform (Android, iOS, etc.). Native capabilities in general can boost your app's performance, engagement, and user satisfaction. Our guide is your one-stop guide to understanding and leveraging platform-specific features within your Flutter applications.

Platform-Specific Features Overview

Writing custom platform-specific code
Learn how to write custom platform-specific code in your app.

Platform-specific features are functionalities that are unique to a particular operating system, such as iOS, Android, or even less common platforms like Windows or macOS. These features take advantage of the specific capabilities and nuances of each platform, allowing apps to deliver a more tailored and optimized experience to users.

Examples of platform-specific features

  1. Access to device hardware: Direct access to the device's hardware components such as the camera, GPS, accelerometer, and gyroscope. For example, using the camera API to capture photos or videos differs significantly between Android and iOS.
  2. System services and APIs: Utilization of system-level services like notifications, background processes, and health data. For instance, iOS has HealthKit, while Android has Google Fit.
  3. User interface elements: Platform-specific UI components and behaviors that conform to each platform’s design guidelines. On iOS, this might include the use of the bottom tab bar, while on Android, it might involve the use of a navigation drawer.
  4. File storage and management: Different approaches to managing files and persistent data, such as access to specific directories, which vary between Android and iOS.
  5. Platform-specific gestures: Handling gestures like swiping, pinching, and tapping can differ due to the inherent differences in how each platform handles touch inputs.

Benefits of integrating native functionalities

By incorporating platform-specific features into your Flutter app, you can unlock several advantages:

  • Enhanced performance: Native features are designed to work optimally on their respective platforms, leading to smoother animations, faster processing, and a more responsive user interface (UI).
  • Improved UX: Utilizing familiar functionalities and UI elements fosters a sense of user comfort and intuitiveness, leading to higher engagement and satisfaction.
  • Richer functionality: Accessing native features opens doors to a wider range of functionalities, allowing you to create more feature-packed and powerful apps.

Access to Native Features in Flutter

While Flutter boasts a robust library of widgets and features, it doesn't encompass every platform-specific functionality. To bridge this gap, Flutter utilizes a communication channel system that allows your Flutter code to interact with native code written in the platform's programming language (Java/Kotlin for Android, Swift/Objective-C for iOS). There are three primary channel types for this purpose:

MethodChannel

MethodChannel class - services library - Dart API
API docs for the MethodChannel class from the services library, for the Dart programming language.

This is the most versatile channel, enabling you to invoke platform-specific methods and receive responses. Here's a simplified example demonstrating how to access device information using a MethodChannel:

final platform = MethodChannel('dev.whattheflutter.platform');

Future<String> readDeviceId() async {
  try {
    final result = await platform.invokeMethod('readDeviceId');
    return result;
  } on PlatformException catch (e) {
    return "Failed to get device ID: ${e.message}";
  }
}

Explanation:

  • We define a platform variable of type MethodChannel with a unique name (dev.whattheflutter.platform). This name helps identify the channel on both the Flutter and native sides.
  • The readDeviceId function is an asynchronous function that retrieves the device ID using the invokeMethod method of the platform channel.
  • The invokeMethod method takes two arguments: the name of the native method to be invoked ('readDeviceId') and an optional map of arguments that can be passed to the native method (empty in this case).
  • The function awaits the result of the native method call and returns the device ID as a string.
  • An error handling block (catch) is included to manage potential exceptions like PlatformException if the native method call fails.

EventChannel

EventChannel class - services library - Dart API
API docs for the EventChannel class from the services library, for the Dart programming language.

This channel is ideal for situations where the native code needs to continuously send data to the Flutter app. For instance, you could use an EventChannel to receive real-time location updates from the device's GPS.

Here's a basic example showcasing a simplified EventChannel for battery level updates:

final batteryLevelStream = EventChannel('dev.whattheflutter.battery')
  .receiveBroadcastStream();

batteryLevelStream.listen((data) => print('Battery level: $data%'));

Explanation:

  • We define a batteryLevelStream variable of type Stream using the EventChannel constructor with a unique name (dev.whattheflutter.battery). This creates a stream that will receive data from the native code.
  • The receiveBroadcastStream method is used to convert the EventChannel into a stream that the Flutter code can listen to.
  • We use the listen method on the stream to receive data updates. In this example, we simply print the battery level to the console.

BasicMessageChannel

BasicMessageChannel class - services library - Dart API
API docs for the BasicMessageChannel class from the services library, for the Dart programming language.

This channel is suited for simpler scenarios where you only need to send or receive small pieces of data between the Flutter and native code.

Here's an example demonstrating how to send a message to the native code using a BasicMessageChannel:

final messageChannel = BasicMessageChannel('dev.whattheflutter.message');

Future<void> sendMessage(String message) async {
  await messageChannel.send(message);
}

Explanation:

  • We define a messageChannel variable of type BasicMessageChannel with a unique name (dev.whattheflutter.message).
  • The sendMessage function takes a string message as input and uses the send method of the messageChannel to send it to the native code.

These are just basic examples, and the specific implementation of these channels on the native side will vary depending on the platform and functionality you're trying to achieve.

Flutter Plugins as an Alternative

Flutter’s extensive ecosystem offers a multitude of packages and plugins that simplify the integration of platform-specific features. These tools not only reduce the complexity of development but also enhance the capabilities of your app. Flutter plugin is a library that implements one of the methods to access native features.

  • url_launcher: This plugin is essential for launching URLs in the default browser, making it easy to open web pages from within your app.
import 'package:url_launcher/url_launcher.dart';

void_launchURL() async {
  const url = Uri.parse('https://www.example.com');
  if (await canLaunchUrl(url)) {
    await launchUrl(url);
  } else {
    throw Exception('Could not launch $url');
  }
}
  • path_provider: This plugin provides a way to access commonly used locations on the file system, such as the temporary and application directories.
import 'package:path_provider/path_provider.dart';
import 'dart:io';

Future<String> get _localPath async {
  final directory = await getApplicationDocumentsDirectory();
  return directory.path;
}

Future<File> get _localFile async {
  final path = await _localPath;
  return File('$path/counter.txt');
}
  • camera: A powerful plugin for accessing the device's cameras to capture photos and videos.
import 'package:camera/camera.dart';

List<CameraDescription> cameras;
CameraController controller;

Future<void> main() async {
  cameras = await availableCameras();
  controller = CameraController(cameras[0], ResolutionPreset.max);
  await controller.initialize();
}
  • geolocator: This plugin provides easy access to the device’s location services, enabling location tracking and geofencing.
import 'package:geolocator/geolocator.dart';

Future<Position> _determinePosition() async {
  bool serviceEnabled;
  LocationPermission permission;

  serviceEnabled = await Geolocator.isLocationServiceEnabled();
  if (!serviceEnabled) {
    return Future.error('Location services are disabled.');
  }

  permission = await Geolocator.checkPermission();
  if (permission == LocationPermission.denied) {
    permission = await Geolocator.requestPermission();
    if (permission == LocationPermission.denied) {
      return Future.error('Location permissions are denied');
    }
  }

  if (permission == LocationPermission.deniedForever) {
    return Future.error(
        'Location permissions are permanently denied, we cannot request permissions.');
  }

  return await Geolocator.getCurrentPosition();
}
  • shared_preferences: This plugin is ideal for storing simple data persistently using key-value pairs, similar to how you might use local storage in a web app.
import 'package:shared_preferences/shared_preferences.dart';

Future<void> _saveData() async {
  final prefs = await SharedPreferences.getInstance();
  await prefs.setInt('counter', 10);
}

Future<int> _loadData() async {
  final prefs = await SharedPreferences.getInstance();
  return prefs.getInt('counter') ?? 0;
}
  • firebase_core: This library is crucial for integrating Firebase services into your Flutter app, providing functionalities like authentication, database, and analytics.
import 'package:firebase_core/firebase_core.dart';

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await Firebase.initializeApp();
  runApp(MyApp());
}
  • sqflite: This plugin allows you to use SQLite, a powerful local database solution, for storing and querying structured data locally.
import 'package:sqflite/sqflite.dart';
import 'package:path/path.dart';

Future<Database> initializeDB() async {
  String path = await getDatabasesPath();
  return openDatabase(
    join(path, 'demo.db'),
    onCreate: (database, version) async {
      await database.execute(
        "CREATE TABLE items(id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL)",
      );
    },
    version: 1,
  );
}

Future<int> insertItem(Item item) async {
  final Database db = await initializeDB();
  return await db.insert('items', item.toMap());
}

What to choose: Flutter plugins or custom implementation?

When deciding between using a Flutter plugin and custom implementation, the choice often hinges on the availability and reliability of existing plugins. If a Flutter plugin is available, well-supported, and likely to be maintained long-term, it is generally advantageous to use the plugin. This approach leverages community expertise, saves development time, and ensures compatibility with platform updates.

However, if a suitable plugin is not available, or if existing options appear unreliable or lack long-term support, custom implementation becomes a viable alternative. Implementing the required functionality through one of the channel methods, developers can ensure the solution meets specific needs and maintains control over the codebase.

Best Practices for Platform-Specific Development in Flutter

Integrating platform-specific features into your Flutter app can unlock a world of possibilities, but it requires careful planning and execution. Here are some best practices to ensure your code remains clean, maintainable, and delivers a flawless user experience across all targeted platforms:

Code clarity and scalability

  • Separate platform-specific code: Keep platform-specific code separate from your core business logic to maintain readability and manageability.
  • Dart abstractions: Create abstract classes or interfaces in Dart to define the contract for platform-specific implementations.
abstract class BatteryLevel {
  Future<String> readBatteryLevel();
}

class BatteryLevelAndroid implements BatteryLevel {
  @override
  Future<String> readBatteryLevel() async {
    const platform = MethodChannel('com.example.battery');
    try {
      final int result = await platform.invokeMethod('readBatteryLevel');
      return 'Battery level at $result % .';
    } on PlatformException catch (e) {
      return "Failed to get battery level: '${e.message}'.";
    }
  }
}
  • Thorough documentation: Clearly document your code and use comments to explain the purpose and functionality of platform-specific implementations.
/// A class that provides the battery level for Android devices.
class BatteryLevelAndroid implements BatteryLevel {
  /// Returns current battery capacity as an integer in a String (ex: '24') 
  @override
  Future<String> readBatteryLevel() async {
    const platform = MethodChannel('com.example.battery');
    try {
      final int result = await platform.invokeMethod('readBatteryLevel');
      return 'Battery level at $result % .';
    } on PlatformException catch (e) {
      return "Failed to get battery level: '${e.message}'.";
    }
  }
}

Testing on multiple devices

  • Diverse device testing: Use emulators and simulators for initial development and testing but validate on physical devices as well. Test on a variety of real devices to catch device-specific issues.
  • Automated testing: Write unit tests for your Dart code. Use integration tests to verify interactions between Flutter and platform-specific code. Implement UI tests to ensure consistent user experience across different devices and screen sizes.
  • User feedback and beta testing: Use platforms like Firebase App Distribution, TestFlight (iOS), and Google Play Beta (Android) to distribute beta versions. Implement in-app feedback mechanisms or use tools like Google Forms and Typeform for feedback from beta testers.
Flutter Mobile App Testing: Tools and Best Practices
Discover how to build an effective Flutter mobile app testing strategy, understand different testing types, tools, and overcome unique challenges.

Version control and continuous integration

  • Use version control effectively: Employ a branching strategy (e.g., Git Flow) to manage development, feature, and release branches. Write clear and descriptive commit messages to document changes.
  • Continuous integration (CI): Configure your CI pipeline to build your app for both Android and iOS. Integrate automated tests into your CI pipeline in order to test platform specific-features or plugins. Automate the deployment process to beta testing platforms and app stores.
Step-by-Step Guide: Set Up CI in Flutter with Codemagic
Discover how to simplify CI for Flutter projects with Codemagic. This guide provides step-by-step instructions from registering to configuring workflows and running builds. Streamline development, automate testing, and ensure smooth app distribution.

Performance monitoring and optimization

  • Performance profiling: Use DevTools to monitor performance, including frame rendering times, CPU usage, and memory consumption. Use Android Studio Profiler and Xcode Instruments to profile native code and identify performance issues.
  • Code optimization: Write efficient Dart code and use platform-specific APIs judiciously. Manage memory effectively, especially for platform-specific resources like images and media files.
How to Improve Flutter App Performance: Best Practices
Discover the best practices and optimization techniques to enhance your Flutter app performance for smooth and efficient operation on all devices.

Conclusion

Incorporating platform-specific features in your Flutter app development can greatly enhance performance and user satisfaction. The important thing is to choose between ready plugins or write a custom one and follow all instructions described in this article.

Implementing platform-specific features in your next Flutter project can be challenging even for technical founders. At What the Flutter, we specialize in high-performance app development, ensuring your app stands out in the competitive market. Reach out to us to discuss how we can help you integrate advanced features seamlessly.