Skip to main content

iOS implementation

Let's use XCode, to write iOS code. Open XCode, by running this command from the root directory of your app:

xed ios

When workspace is opened, locate Pods project and expand it. Search for Development Pods and find SaveFilePickerPackage inside. When it's expanded, it will show all files that we created under save-file-package/ios directory.

info

The main purpose of this guide is to show you how to wrap platform APIs in asynchronous methods. So to simplify the example, the module will be sharing files only from application static local assets. After you complete this guide, you can try to refactor the module to accept the path to any file (e.g. from the documents or cache directory).

SaveFilePickerPackage-Bridging-Header.h

To use some Objective-C API in Swift it needs to be added to the bridging header. In our case, we need to use RCTPresentedViewController function from React/RCTUtils.h in our Swift code, so let's add it here:

ios/SaveFilePickerPackage-Bridging-Header.h
#import <React/RCTUtils.h>

SaveFilePickerModuleImpl.swift

Let's start by creating a Swift implementation that will directly call iOS save file picker APIs:

ios/SaveFilePickerModuleImpl.swift
import UIKit

@objc(SaveFilePickerModuleDelegate)
public protocol SaveFilePickerModuleDelegate: AnyObject {
func onCancel()
func onError(_ error: Error)
func onSuccess()
}

/**
* Native module's shared implementation
*/
@objc(SaveFilePickerModuleImpl)
public class SaveFilePickerModuleImpl : NSObject {
@objc public weak var delegate: SaveFilePickerModuleDelegate? = nil

static let INVALID_PATH_ERROR_MESSAGE = "Invalid path"
static let NO_VIEW_CONTROLLER_ERROR_MESSAGE = "No viewcontroller"

@objc public func saveFileWithFilename(_ filename: String) {

}
}

Here, we declare SaveFilePickerModuleImpl class together with SaveFilePickerModuleDelegate protocol.

info

To make Swift elements accessible to Objective-C world, we have to do 4 things:

  • make class extending NSObject
  • mark class and its methods (at least those methods that are meant to be exposed) as public
  • mark class with @objc(exported-objc-name) decorator
  • mark exposed methods with @objc decorator

The class will have weak delegate property and one method. For error handling, we also declare 2 static error messages, that will be used later.

For saving file functionality we will use UIDocumentPickerViewController, more specifically its init(forOpeningContentTypes:asCopy:) initializer.

And to consume the result from the picker, the Swift class needs to implement UIDocumentPickerDelegate.

ios/SaveFilePickerModuleImpl.swift
/**
* Native module's shared implementation
*/
@objc(SaveFilePickerModuleImpl)
public class SaveFilePickerModuleImpl : NSObject {
@objc public weak var delegate: SaveFilePickerModuleDelegate? = nil

static let INVALID_PATH_ERROR_MESSAGE = "Invalid path"
static let NO_VIEW_CONTROLLER_ERROR_MESSAGE = "No viewcontroller"

@objc public func saveFileWithFilename(_ filename: String) {
let filenameComponents = filename.components(separatedBy: ".")
guard let url = Bundle.main.url(forResource: filenameComponents[0], withExtension: filenameComponents[1]) else {
let error = SaveFilePickerError.InvalidPath(SaveFilePickerModuleImpl.INVALID_PATH_ERROR_MESSAGE)
delegate?.onError(error)
return
}

guard let presentedViewController = RCTPresentedViewController() else {
let error = SaveFilePickerError.NoViewController(SaveFilePickerModuleImpl.NO_VIEW_CONTROLLER_ERROR_MESSAGE)
delegate?.onError(error)
return
}

let documentPicker = self.createDocumentPicker(with: url)
documentPicker.delegate = self
presentedViewController.present(documentPicker, animated: true)
}

private func createDocumentPicker(with url: URL) -> UIDocumentPickerViewController {
if #available(iOS 14.0, *) {
return UIDocumentPickerViewController(forExporting: [url], asCopy: true)
}
return UIDocumentPickerViewController(url: url, in: .exportToService)
}
}

extension SaveFilePickerModuleImpl : UIDocumentPickerDelegate {
public func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
delegate?.onSuccess()
controller.delegate = nil
}

public func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) {
delegate?.onCancel()
controller.delegate = nil
}
}

enum SaveFilePickerError: Error {
case InvalidPath(String)
case NoViewController(String)
}

First take a look at the UIDocumentPickerDelegate implementation - we are implementing two methods, one is returning the urls where the file was saved and the other is called when user cancelled the picker. In those methods, we have to call SaveFilePickerModuleDelegate delegate to communicate success or cancellation. When it's done, the picker's delegate controller.delegate needs to be cleared.

Now we can start implementing saveFileWithFilename method. We begin with getting the URL for the file from local assets based on the filename argument. To do that, we use url(forResource:withExtension:) on main bundle instance. If it cannot be obtained, we have to early-return and communicate to the delegate, that there's no such file with that path. To construct the Error struct we are using custom enum SaveFilePickerError defined at the bottom of the file.

tip

For more on custom errors in Swift, you may visit this article.

Next step is to take currently presented view controller (RCTPresentedViewController, that was added to bridging header, is used to grab it) - will use it to modally display the file picker. As with the url, we also have to do error handling for the case when there's no presented view controller (~0.001% chance, but we should still do it).

Finally, we create the picker and present it modally. To create the picker we are using createDocumentPicker helper. It's because the initializer we want to use is available from iOS 14, but our package declares support down to iOS 13. To handle it, we are using availability check (if #available) to use new initilaizer for iOS 14+ and fall back to old initializer for iOS 13.

tip

For more on availability API, you can visit Apple dedicated docs's section.

When the picker is initialized, we have to set its delegate property to this module instance and present it.

SaveFilePickerModule.h

Now, let's move to the module that will manage function calls from the JS world:

ios/SaveFilePickerModule.h
#import <React/RCTBridgeModule.h>

/**
* When using Swift classes in ObjC header, the class must have its
* "forward declaration"
*
* @see https://developer.apple.com/documentation/swift/importing-swift-into-objective-c#Include-Swift-Classes-in-Objective-C-Headers-Using-Forward-Declarations
*/
@class SaveFilePickerModuleImpl;


/**
* Declare the ObjC interface for that native module class.
*
* It must extend NSObject (like every class in ObjC) and
* implement RCTBridgeModule (like each RN native module).
*
* If the module emits events, it must extend RCTEventEmitter class.
*/
@interface SaveFilePickerModule : NSObject<RCTBridgeModule>

@end

In the header file for SaveFilePickerModule class we declare our class extending NSObject (as every class in Objective-C) and implementing RCTBridgeModule (as each RN iOS module class).

Additionally, to use Swift SaveFilePickerModuleImpl class, we must do "forward-declaration" (check out Apple's Swift-ObjC interop dedicated docs section).

SaveFilePickerModule.mm

ios/SaveFilePickerModule.mm
#import "SaveFilePickerModule.h"

/**
* When using Swift classes in ObjC implementation, the classes must be imported
* from generated Objective-C Interface Header
*
* @see https://developer.apple.com/documentation/swift/importing-swift-into-objective-c#Import-Code-Within-an-App-Target
*/
#import "SaveFilePickerPackage-Swift.h"

#if RCT_NEW_ARCH_ENABLED
/**
* Import header file with codegenerated protocols based on the JS specification
*
* The name of the header matches the name provided in codegenConfig's `name` field in package.json
*/
#import "SaveFilePickerPackage.h"

// Each turbo module extends codegenerated spec class
@interface SaveFilePickerModule () <NativeSaveFilePickerModuleSpec>
@end
#endif

// Declare the ObjC implementation for that native module class
@implementation SaveFilePickerModule

// Return the name of the module - it should match the name provided in JS specification
RCT_EXPORT_MODULE(SaveFilePickerModule)

#if RCT_NEW_ARCH_ENABLED
// Implement RCTTurboModule protocol
- (std::shared_ptr<facebook::react::TurboModule>)getTurboModule:
(const facebook::react::ObjCTurboModule::InitParams &)params
{
return std::make_shared<facebook::react::NativeSaveFilePickerModuleSpecJSI>(params);
}
#endif

@end

This is the implementation file for SaveFilePickerModule class.

Before declaring the implementation block, we have to import Swift-ObjC generated interface header (check out dedicated docs section), and we have to implement code-generated spec protocol for new architecture (code between #if RCT_NEW_ARCH_ENABLED & #endif directives).

After that, inside implementation block we have RCT_EXPORT_MODULE macro invoked with SaveFilePickerModule argument. This basically registers the module with provided name on iOS side, so that it's accessible in the JS world.

Also, for new architecture, it implements - getTurboModule: method (from RCTTurboModule protocol). If you won't implement that method, XCode will complain that the code-generated spec protocol methods are not implemented.

Let's use SaveFilePickerModuleImpl class

ios/SaveFilePickerModule.mm
// ...

@interface SaveFilePickerModule () <SaveFilePickerModuleDelegate>
@end

// Declare the ObjC implementation for that native module class
@implementation SaveFilePickerModule {
SaveFilePickerModuleImpl *moduleImpl;
RCTResponseSenderBlock callbackBlock;
RCTPromiseResolveBlock resolveBlock;
RCTPromiseRejectBlock rejectBlock;
}

// Return the name of the module - it should match the name provided in JS specification
RCT_EXPORT_MODULE(SaveFilePickerModule)

- (instancetype)init {
self = [super init];
if (self) {
moduleImpl = [SaveFilePickerModuleImpl new];
moduleImpl.delegate = self;
}
return self;
}

// Declare if module should be initialized on the main queue
+ (BOOL)requiresMainQueueSetup
{
return YES;
}

/**
* If the module interacts with UIKit,
* it can declare that its methods should be run on main queue
*/
- (dispatch_queue_t)methodQueue {
return dispatch_get_main_queue();
}

// ...

@end

First, we declare private variable on SaveFilePickerModule that will be initialized in the - init method. We can use SaveFilePickerModuleImpl Swift class, because it's imported from SaveFilePickerPackage-Swift.h.

We also declared private variables for callback and promise blocks, we will use them to communicate result back to the JS code.

Next, we override static method + requiresMainQueueSetup, which must be done whenever - init method on the RN's wrapper class is overriden.

As we will interact with the UIKit API, we will need to do it on the main queue. We could just wrap UIKit code in dispatch_async(dispatch_get_main_queue(), ^{ ... });, but there's also an option to specify the - methodQueue getter. It's used to describe on which queue this module calls should be run. So in our case, we will want to use the main queue - returning dispatch_get_main_queue() from the getter will do the job.

tip

To learn more about dispatch queues in Objective-C, you may visit Apple's dedicated guide.

We still have 2 things to do - as you may noticed, XCode displayed a warning about SaveFilePickerModuleDelegate. The module class is marked to be implementing the delegate protocol, the SaveFilePickerModuleImpl delegate property is set to this module instance, but we still need to implement delegate's methods.

ios/SaveFilePickerModule.mm
// ...

@implementation SaveFilePickerModule {
SaveFilePickerModuleImpl *moduleImpl;
RCTResponseSenderBlock callbackBlock;
RCTPromiseResolveBlock resolveBlock;
RCTPromiseRejectBlock rejectBlock;
}

// ...

/**
* If the module interacts with UIKit,
* it can declare that its methods should be run on main queue
*/
- (dispatch_queue_t)methodQueue {
return dispatch_get_main_queue();
}

- (void)onSuccess
{
if (callbackBlock != nil) {
callbackBlock(@[@{ @"success": @(YES), @"cancelled": @(NO) }]);
} else if (resolveBlock != nil) {
resolveBlock(@(YES));
}
callbackBlock = nil;
resolveBlock = nil;
rejectBlock = nil;
}

- (void)onCancel
{
if (callbackBlock != nil) {
callbackBlock(@[@{ @"success": @(NO), @"cancelled": @(YES) }]);
} else if (resolveBlock != nil) {
resolveBlock(@(NO));
}
callbackBlock = nil;
resolveBlock = nil;
rejectBlock = nil;
}

- (void)onError:(NSError *)error
{
if (callbackBlock != nil) {
callbackBlock(@[@{ @"success": @(NO), @"cancelled": @(NO), @"error": @{ @"code": @(error.code), @"message": error.localizedDescription } }]);
} else if (rejectBlock != nil) {
rejectBlock([NSString stringWithFormat:@"%@", @(error.code)], error.localizedDescription, error);
}
callbackBlock = nil;
resolveBlock = nil;
rejectBlock = nil;
}

@end

In the delegate's methods, we are invoking callback/promise response blocks, with the result arguments. As a cleanup, we are setting response blocks to nil, so that these are not called second time.

The last thing is to export the methods to the JS layer.

ios/SaveFilePickerModule.mm
// ...

@implementation SaveFilePickerModule {
SaveFilePickerModuleImpl *moduleImpl;
RCTResponseSenderBlock callbackBlock;
RCTPromiseResolveBlock resolveBlock;
RCTPromiseRejectBlock rejectBlock;
}

// ...

/**
* If the module interacts with UIKit,
* it can declare that its methods should be run on main queue
*/
- (dispatch_queue_t)methodQueue {
return dispatch_get_main_queue();
}

// Exported methods are overriden - based on the spec class
RCT_EXPORT_METHOD(saveFileWithCallback : (NSString *)filename
callback : (RCTResponseSenderBlock)callback)
{
callbackBlock = callback;
[moduleImpl saveFileWithFilename:filename];
}

RCT_EXPORT_METHOD(saveFileWithPromise : (NSString *)filename
resolve : (RCTPromiseResolveBlock)resolve
reject : (RCTPromiseRejectBlock)reject)
{
resolveBlock = resolve;
rejectBlock = reject;
[moduleImpl saveFileWithFilename:filename];
}

- (void)onSuccess
{
if (callbackBlock != nil) {
callbackBlock(@[@{ @"success": @(YES), @"cancelled": @(NO) }]);
} else if (resolveBlock != nil) {
resolveBlock(@(YES));
}
callbackBlock = nil;
resolveBlock = nil;
rejectBlock = nil;
}

- (void)onCancel
{
if (callbackBlock != nil) {
callbackBlock(@[@{ @"success": @(NO), @"cancelled": @(YES) }]);
} else if (resolveBlock != nil) {
resolveBlock(@(NO));
}
callbackBlock = nil;
resolveBlock = nil;
rejectBlock = nil;
}

- (void)onError:(NSError *)error
{
if (callbackBlock != nil) {
callbackBlock(@[@{ @"success": @(NO), @"cancelled": @(NO), @"error": @{ @"code": @(error.code), @"message": error.localizedDescription } }]);
} else if (rejectBlock != nil) {
rejectBlock([NSString stringWithFormat:@"%@", @(error.code)], error.localizedDescription, error);
}
callbackBlock = nil;
resolveBlock = nil;
rejectBlock = nil;
}

@end

Inside exported methods, we are doing 2 things:

  • we save callback/promise response blocks
  • we use SaveFilePickerModuleImpl to show the picker
Complete SaveFilePickerModule.mm file
#import "SaveFilePickerModule.h"

/**
* When using Swift classes in ObjC implementation, the classes must be imported
* from generated Objective-C Interface Header
*
* @see https://developer.apple.com/documentation/swift/importing-swift-into-objective-c#Import-Code-Within-an-App-Target
*/
#import "SaveFilePickerPackage-Swift.h"

#if RCT_NEW_ARCH_ENABLED
/**
* Import header file with codegenerated protocols based on the JS specification
*
* The name of the header matches the name provided in codegenConfig's `name` field in package.json
*/
#import "SaveFilePickerPackage.h"

// Each turbo module extends codegenerated spec class
@interface SaveFilePickerModule () <NativeSaveFilePickerModuleSpec>
@end
#endif

@interface SaveFilePickerModule () <SaveFilePickerModuleDelegate>
@end

// Declare the ObjC implementation for that native module class
@implementation SaveFilePickerModule {
SaveFilePickerModuleImpl *moduleImpl;
RCTResponseSenderBlock callbackBlock;
RCTPromiseResolveBlock resolveBlock;
RCTPromiseRejectBlock rejectBlock;
}

// Return the name of the module - it should match the name provided in JS specification
RCT_EXPORT_MODULE(SaveFilePickerModule)

- (instancetype)init {
self = [super init];
if (self) {
moduleImpl = [SaveFilePickerModuleImpl new];
moduleImpl.delegate = self;
}
return self;
}

// Declare if module should be initialized on the main queue
+ (BOOL)requiresMainQueueSetup
{
return YES;
}

/**
* If the module interacts with UIKit,
* it can declare that its methods should be run on main queue
*/
- (dispatch_queue_t)methodQueue {
return dispatch_get_main_queue();
}

// Exported methods are overriden - based on the spec class
RCT_EXPORT_METHOD(saveFileWithCallback : (NSString *)filename
callback : (RCTResponseSenderBlock)callback)
{
callbackBlock = callback;
[moduleImpl saveFileWithFilename:filename];
}

RCT_EXPORT_METHOD(saveFileWithPromise : (NSString *)filename
resolve : (RCTPromiseResolveBlock)resolve
reject : (RCTPromiseRejectBlock)reject)
{
resolveBlock = resolve;
rejectBlock = reject;
[moduleImpl saveFileWithFilename:filename];
}

- (void)onSuccess
{
if (callbackBlock != nil) {
callbackBlock(@[@{ @"success": @(YES), @"cancelled": @(NO) }]);
} else if (resolveBlock != nil) {
resolveBlock(@(YES));
}
callbackBlock = nil;
resolveBlock = nil;
rejectBlock = nil;
}

- (void)onCancel
{
if (callbackBlock != nil) {
callbackBlock(@[@{ @"success": @(NO), @"cancelled": @(YES) }]);
} else if (resolveBlock != nil) {
resolveBlock(@(NO));
}
callbackBlock = nil;
resolveBlock = nil;
rejectBlock = nil;
}

- (void)onError:(NSError *)error
{
if (callbackBlock != nil) {
callbackBlock(@[@{ @"success": @(NO), @"cancelled": @(NO), @"error": @{ @"code": @(error.code), @"message": error.localizedDescription } }]);
} else if (rejectBlock != nil) {
rejectBlock([NSString stringWithFormat:@"%@", @(error.code)], error.localizedDescription, error);
}
callbackBlock = nil;
resolveBlock = nil;
rejectBlock = nil;
}

#if RCT_NEW_ARCH_ENABLED
// Implement RCTTurboModule protocol
- (std::shared_ptr<facebook::react::TurboModule>)getTurboModule:
(const facebook::react::ObjCTurboModule::InitParams &)params
{
return std::make_shared<facebook::react::NativeSaveFilePickerModuleSpecJSI>(params);
}
#endif

@end

You can check training repo for Objective-C & Swift implementation here and Objective-C-only implementation here.

That's iOS part, now let's go to Android!