Categories:
Search articles

Search for articles

Sorry, but we couldn't find any matches...

But perhaps we can interest you in one of our more popular articles?

How to add Flutter modules to native iOS project and test it on Codemagic

Feb 16, 2020

Written by Souvik Biswas

In this article we will learn how to add Flutter to your new or existing Native iOS project and how to test it on Codemagic CI/CD using codemagic.yaml file.

Let's start by building a native iOS app.

App Overview

The app that we will be looking at is a simple BMI Calculator app.

It will have two screens:

1. BMI Calculation Screen (using Native iOS)

2. BMI Result Screen (using Flutter Module)

Creating the Native iOS app

1. Start Xcode on your system.

2. Click on Create a new Xcode project.

3. Go to the iOS tab and select Single View App. Click Next.

4. Enter the Product Name, select Team, and fill in the other details. Then click Next.

This will create an empty iOS project with all the required files.

Working on the iOS app

First of all, we have to complete the UI of the app. So, go to the file Main.storyboard and complete the UI design.

Main.storyboard
Main.storyboard

This will contain only one screen with some basic components. Link the UI components to the ViewController.swift file.

// ViewController.swift

import UIKit

class ViewController: UIViewController {
    
    // Reference to the UI components
    @IBOutlet weak var heightLabel: UILabel!
    @IBOutlet weak var weightLabel: UILabel!
    @IBOutlet weak var heightSlider: UISlider!
    @IBOutlet weak var weightSlider: UISlider!
    
    override func viewDidLoad() {
        super.viewDidLoad()
    }

    @IBAction func heightSliderChanged(_ sender: UISlider) {
        // Perform some action when height slider position
        // is changed.
    }
    
    @IBAction func weightSliderChanged(_ sender: UISlider) {
        // Perform some action when weight slider position
        // is changed.
    }
    
    @IBAction func calculatePressed(_ sender: UIButton) {
        // Perform some action when calculate button
        // is pressed.
    }

}

Now, we will be creating two new files to keep the project organized:

  • BMI.swift (This file will contain a BMI struct for structuring important BMI information)
  • CalculatorBrain.swift (This file will be responsible for retrieving data and calculating the BMI of a person)
// BMI.swift

import UIKit

struct BMI {
    let value: Float
    let advice: String
    let color: String
}
// CalculatorBrain.swift

import UIKit

struct CalculatorBrain {
    var bmi: BMI?
    
    func getBMIValue() -> String {
        let bmiTo1DecimalPlace = String(format: "%.1f", bmi?.value ?? 0.0)
        return bmiTo1DecimalPlace
    }
    
    func getAdvice() -> String {
        return bmi?.advice ?? "No advice"
    }
    
    func getColor() -> String {
        return bmi?.color ?? "white"
    }
    
    mutating func calculateBMI(_ weight: Float, _ height: Float) {
        let bmiValue = weight / pow(height, 2)
        
        if bmiValue < 18.5 {
            bmi = BMI(value: bmiValue, advice: "Eat more pies!", color: "blue")
        } else if bmiValue < 24.9 {
            bmi = BMI(value: bmiValue, advice: "Fit as a fiddle!", color: "green")
        } else {
            bmi = BMI(value: bmiValue, advice: "Eat less pies!", color: "pink")
        }
    }
}

Complete implementing the methods of ViewController.swift file.

// ViewController.swift
// (with methods implemented)

import UIKit

class ViewController: UIViewController {
    
    var calculatorBrain = CalculatorBrain()
    
    @IBOutlet weak var heightLabel: UILabel!
    @IBOutlet weak var weightLabel: UILabel!
    @IBOutlet weak var heightSlider: UISlider!
    @IBOutlet weak var weightSlider: UISlider!
    
    override func viewDidLoad() {
        super.viewDidLoad()
    }

    @IBAction func heightSliderChanged(_ sender: UISlider) {
        heightLabel.text = String(format: "%.2fm", sender.value)
    }
    
    @IBAction func weightSliderChanged(_ sender: UISlider) {
        weightLabel.text = String(format: "%.0fKg", sender.value)
    }
    
    @IBAction func calculatePressed(_ sender: UIButton) {
        let height = heightSlider.value
        let weight = weightSlider.value
        
        calculatorBrain.calculateBMI(weight, height)
        
        let bmiValue = calculatorBrain.getBMIValue()
        let bmiAdvice = calculatorBrain.getAdvice()
        let bmiColor = calculatorBrain.getColor()
        
        print(bmiValue)
        print(bmiAdvice)
        print(bmiColor)
    }

}

If you run the app now, you will be able to see the calculated BMI value, advice and color value in the Xcode Console.

We will be sending these parameters to the BMI Result Screen (created using Flutter modules) through Platform Channel.

Creating Flutter Module

You should keep both the iOS project folder and the Flutter module folder in a root folder for easy accessibility.

The project directory structure followed by me for this app is as below:

Run this command from the root directory to create the Flutter module:

flutter create --template module <module_name>

Or, you can also run this similar command:

flutter create -t module <module_name>

Replace the text in angle brackets with proper information.

Creating Flutter module
Creating Flutter module

--org <organization_id> is an optional parameter by which you can specify the organization id for this module. By default it is set to com.example.

Open the Flutter module folder using any code editor. You will find that it contains the demo Flutter Counter app.

Run the Flutter module on physical iOS device or Simulator to test if it is working properly.

Now, let's move on to integrating the Flutter module to the native iOS project.

Integrating Flutter module to native iOS

The easiest option to integrate Flutter module to native iOS is using CocoaPods.

There are two other options for integrating Flutter module to a native iOS app, you can find them here.

If you do not have CocoaPods installed on your system, you can find the installation guide here.

Now, we have to create a Podfile.

If you already have a Podfile created in your iOS project, then you can skip the first step.

1. Run this command from the iOS project directory:

pod init

After creating the Podfile the whole project structure should be like this:

 bmi_calculator/
 ├── bmi_flutter/
 │   └── .ios/
 │       └── Flutter/
 │         └── podhelper.rb
 └── BMI Calculator/
     └── Podfile

2. Now, open the Podfile using Xcode.

3. Add the following code to your Podfile:

flutter_application_path = '../my_flutter'
    
load File.join(flutter_application_path, '.ios', 'Flutter', 'podhelper.rb')

Replace my_flutter with the name of your Flutter module folder.

4. For each Podfile target that needs to embed Flutter, you have to add this line:

install_all_flutter_pods(flutter_application_path).

5. Save and close the Podfile as well the Xcode.

6. Finally, run the following command to install the Pods:

pod install

7. Reopen the project using the file with .xcworkspace extension.

If you open the file with .xcodeproject extension, then you will get a lot of errors as the Podfile cannot be used with it.

You can refer to the Flutter Official Docs for more information.

Adding a Flutter screen to the iOS app

FlutterViewController is used to display a Flutter screen inside the native iOS app. But for using this, we will also need to create a FlutterEngine.

The proper place to create a FlutterEngine is inside the AppDelegate.swift file.

Replace the whole content of this file with the following code:

// AppDelegate.swift

import UIKit
import Flutter
import FlutterPluginRegistrant

@UIApplicationMain
class AppDelegate: FlutterAppDelegate {
    
    var flutterEngine : FlutterEngine?;
    
    override func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
        self.flutterEngine = FlutterEngine(name: "io.flutter", project: nil);
        self.flutterEngine?.run(withEntrypoint: nil);
        GeneratedPluginRegistrant.register(with: self.flutterEngine!);
        return super.application(application, didFinishLaunchingWithOptions: launchOptions);
    }

}

Now, we can just add the following lines to the ViewController.swift file in order to display the flutter screen:

// ViewController.swift

// ...
import Flutter

class ViewController: UIViewController {
    
    // ...
    
    @IBAction func calculatePressed(_ sender: UIButton) {

        // ...

        let flutterEngine = ((UIApplication.shared.delegate as? AppDelegate)?.flutterEngine)!;
        let flutterViewController = FlutterViewController(engine: flutterEngine, nibName: nil, bundle: nil);
        self.present(flutterViewController, animated: true, completion: nil)

    }

}

If you run your app now, you should be able to view the Flutter Demo Counter app screen by tapping on the CALCULATE button.

You can refer to the Flutter Official Docs for more information.

Completing the Flutter UI

The UI for the BMI Result Screen will be really simple, with just a few Text widgets.

Code for the BMI Result Screen UI:

// main.dart

import 'dart:convert';

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'BMI Calculator Module',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: MyHomePage(),
      debugShowCheckedModeBanner: false,
    );
  }
}

class MyHomePage extends StatefulWidget {
  @override
  _MyHomePageState createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {

  Future<void> _receiveFromHost(MethodCall call) async {
    // To be implemented.

    // Will be used for retrieving data passed from
    // the native iOS app.
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Container(
        color: Colors.blue,
        child: Center(
          child: Column(
            mainAxisSize: MainAxisSize.max,
            mainAxisAlignment: MainAxisAlignment.center,
            crossAxisAlignment: CrossAxisAlignment.center,
            children: <Widget>[
              Text(
                'YOUR BMI',
                style: TextStyle(
                    color: Colors.white,
                    fontSize: 40,
                    fontWeight: FontWeight.bold),
              ),
              Text(
                '23.7',
                style: TextStyle(
                    color: Colors.white,
                    fontSize: 70,
                    fontWeight: FontWeight.bold),
              ),
              Text(
                'Fit as a fiddle!',
                style: TextStyle(
                  color: Colors.white,
                  fontSize: 20,
                ),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

Using Platform Channel

We will be using Platform Channel to pass data from the native iOS app to the Flutter module.

Setting up the Flutter Module (receiving data)

1. First of all, we have to create a channel with some name, inside the class _MyHomePageState.

// main.dart

static const platform = const MethodChannel('com.souvikbiswas.bmi/data');

2. Implement the method _receiveFromHost(), which will retrieve the data passed from the native part and get the data to display inside the Flutter module.

// main.dart

Future<void> _receiveFromHost(MethodCall call) async {
   var jData;
 
   try {
     print(call.method);
 
     if (call.method == "fromHostToClient") {
       final String data = call.arguments;
       print(call.arguments);
       jData = await jsonDecode(data);
     }
   } on PlatformException catch (error) {
     print(error);
   }
 
   setState(() {
     jData1 = jData;
     if (jData['color'] == 'blue') {
       color = Colors.blue;
     } else if (jData['color'] == 'green') {
       color = Colors.green;
     } else {
       color = Colors.pink;
     }
   });
 }

3. Initialize the platform in the _MyHomePageState() constructor and set the _receiveFromHost() method as Method Call Handler.

// main.dart

_MyHomePageState() {
     platform.setMethodCallHandler(_receiveFromHost);
}

4. Update the build method to show the data retrieved using the platform channel:

// main.dart

 @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Container(
        color: color, // Updated
        child: Center(
          child: Column(
            // ...
            children: <Widget>[
              Text(
                'YOUR BMI',
                // ...
              ),
              Text(
                jData1['value'], // Updated
                // ...
              ),
              Text(
                jData1['advice'], // Updated
                // ...
              ),
            ],
          ),
        ),
      ),
    );
  }
}

Setting up the iOS app (sending data)

1. Creating a channel for communicating with the Flutter module

// Define inside calculatePressed() method

let bmiDataChannel = FlutterMethodChannel(name: "com.souvikbiswas.bmi/data", binaryMessenger: flutterViewController.binaryMessenger)

2. Sending data using the channel

// Define inside calculatePressed() method

 let bmiDataChannel = FlutterMethodChannel(name: "com.souvikbiswas.bmi/data", binaryMessenger: flutterViewController.binaryMessenger)
     
 let jsonObject: NSMutableDictionary = NSMutableDictionary()

 jsonObject.setValue(bmiValue, forKey: "value")
 jsonObject.setValue(bmiAdvice, forKey: "advice")
 jsonObject.setValue(bmiColor, forKey: "color")
 
 var convertedString: String? = nil
 
 do {
     let data1 =  try JSONSerialization.data(withJSONObject: jsonObject, options: JSONSerialization.WritingOptions.prettyPrinted)
     convertedString = String(data: data1, encoding: String.Encoding.utf8)
 } catch let myJSONError {
     print(myJSONError)
 }
     
 bmiDataChannel.invokeMethod("fromHostToClient", arguments: convertedString)

Using Hot Reload & Hot Restart

You might be missing the Hot Reload and Hot Restart feature of Flutter. Though you are running an instance of Flutter within the native iOS app, still you will be able to use these Flutter features.

To use Hot Reload and Hot Restart, follow these steps:

1. Run the following command from the Flutter project directory:

flutter attach

2. Immediately, go to Xcode and run the app on device by clicking on the Run button present at the top-left corner.

3. If you take a look at the Terminal, you will notice that the connection is successful and you can now use Hot reload & Hot restart for the Flutter module running inside a native iOS app.

Building on Codemagic (using YAML)

For building a native iOS app with Flutter module, we have to use codemagic.yaml file. Codemagic has recently announced YAML support for building and testing iOS apps.

I will be showing how you can define a correct pipeline for building and testing a native iOS app with a Flutter module.

Downloading the YAML file

1. First of all, push the code to online VCS.

Add the contents of the root folder to VCS (including both the native iOS folder and the Flutter module folder)

2. Go to Codemagic UI and Login.

3. In the Applications dashboard, search for your project and go to its Settings.

4. Then, on the right side bar under Configuration as code, click Download configuration. This generates a codemagic.yaml file with some default configurations.

5. After downloading the file, open it in a code editor.

The codemagic.yaml file will contain a pipeline for building and testing a default Flutter app. But in order to use this for native iOS app with Flutter modules, we need to make some modifications to it.

Adding Environment variables

In order to generate iOS .ipa file, we need to set up code signing for the app.

Two files are required for iOS code signing:

  • Certificate
  • Provisioning Profile

In the environment variables section of the codemagic.yaml file, we can add the certificate and the provisioning profile in encrypted form as key-value pairs.

So, we will be adding the following keys:

  • CM_CERTIFICATE (Encrypted version of the Certificate)
  • CM_CERTIFICATE_PASSWORD (Encrypted version of Certificate Password)
  • CM_PROVISIONING_PROFILE (Encrypted version of Provisioning Profile)

You can generate the encrypted version of the files / variables by following these steps.

  1. Go to the project Settings from Codemagic UI.
  2. In the right sidebar, click Encrypt environment variables.

Here, you can drag and drop the files or type in the variable to generate their encrypted version.

Defining the script

I will be going through the build script, explaining line-by-line.

Also, as we have two folders, one for native iOS and the other for the Flutter module, we have to make sure that the commands are run in the correct folder.

So, let's get started.

  • Getting Flutter packages

    - cd $FCI_BUILD_DIR/bmi_flutter && flutter packages pub get
    
  • Installing the Podfile

    - find . -name "Podfile" -execdir pod install \;
    
  • Initializing the keychain

    - keychain initialize
    
  • Decoding the Provisioning Profile and placing it in a folder from where it can be accessed while performing code signing

    PROFILES_HOME="$HOME/Library/MobileDevice/Provisioning Profiles"
    mkdir -p "$PROFILES_HOME"
    PROFILE_PATH="$(mktemp "$PROFILES_HOME"/$(uuidgen).mobileprovision)"
    echo ${CM_PROVISIONING_PROFILE} | base64 --decode > $PROFILE_PATH
    echo "Saved provisioning profile $PROFILE_PATH"
    
  • Decoding the Signing Certificate and adding it to the keychain

    echo $CM_CERTIFICATE | base64 --decode > /tmp/certificate.p12
    keychain add-certificates --certificate /tmp/certificate.p12 --certificate-password $CM_CERTIFICATE_PASSWORD
    
  • Build the Flutter module only (this step is necessary in order to generate some files required by the native iOS app in order to retrieve the Flutter module)

    - cd $FCI_BUILD_DIR/bmi_flutter && flutter build ios --release --no-codesign
    
  • Using a profile and building the native iOS app with Flutter module to generate .ipa file

    - cd $FCI_BUILD_DIR/BMI\ Calculator && xcode-project use-profiles
    - xcode-project build-ipa --workspace "$FCI_BUILD_DIR/BMI Calculator/BMI Calculator.xcworkspace" --scheme "BMI Calculator"
    

    TIPS: You can add this flag --disable-xcpretty to view the whole Xcode verbose output directly on the Codemagic UI during build.

    You can get more information about configuration options for the build‑ipa command here.

Retrieve the generated artifacts

To get the .ipa file after build, you need to define the correct path to the generated artifact.

artifacts:
  - build/ios/ipa/*.ipa

Before running the script, don't forget to place the codemagic.yaml file in the root directory of the project.

Running the build script

1. Go to your project from the Applications dashboard on Codemagic UI.

2. Click on Start new build.

3. Click on Select workflow from codemagic.yaml.

4. Select the correct workflow and click on Start new build to start the building process.

Congratulations! You have successfully completed the building process on Codemagic using YAML.

Testing on Codemagic (using YAML)

It's very simple to perform any testing on Codemagic using YAML.

Let's add a demo unit test to the Flutter module.

import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';

void main() {
  String jsonTest = '{' +
      '"value" : "44.4",' +
      '"color" : "pink",' +
      '"advice" : "Eat less pies!"' +
      '}';

  var jData = jsonDecode(jsonTest);
  test("Json Data Test", (){
    expect(jData['value'], "44.4");
    expect(jData['color'], "pink");
    expect(jData['advice'], "Eat less pies!");
  }); 
}

In order to test it on Codemagic CI/CD, we have to just include one more line to our script in codemagic.yaml file.

Add this line before the ios build:

- cd $FCI_BUILD_DIR/bmi_flutter && flutter test

The whole YAML script for this build is available here.

Conclusion

The codemagic.yaml file makes it possible to build and test a native iOS app with a Flutter module on Codemagic CI/CD. Maintaining the YAML file is convenient, it gets checked into the VCS and is auto-detected by Codemagic CI/CD during the build. Access it locally and you can modify it even without internet connection.

  • The official documentation for integrating a Flutter module into your iOS project is here.
  • The official documentation for Codemagic YAML is available here.
  • You can get more information about the CLI tools here.
  • The GitHub repo for this project is available here.

Souvik Biswas is a passionate Mobile App Developer (Android and Flutter). He has worked on a number of mobile apps throughout his journey. Loves open source contribution on GitHub. He is currently pursuing a B.Tech degree in Computer Science and Engineering from Indian Institute of Information Technology Kalyani. He also writes Flutter articles on Medium - Flutter Community.

More articles by Souvik: