Sharing content to your app – iOS custom view

In my previous post I showed how to create a Share Extension in Ionic Capacitor app. Now it’s time to make this example a little more complicated. This post will cover how to:

  1. Create own UI for Share Extension using Storyboards (you can use SwiftUI as well)- instead of default built on top of SLComposeServiceViewController
  2. Feed the UI with data from App – let’s say you built a communicator with a friends list. One potential usage could be showing the list of members to whom user may send a message
tldr;
As usual you can jump directly to this repository and grab all the prepared examples. This post will be under branch post-2

The code described below is a continuation from the previous post. You can download the code from branch post-1.

Custom UIController

First we start from removing changing ShareViewController to derive from UIVIewController instead of SLComposeServiceViewController.

Now we can customize UI a little. To do that go to ShareViewController and add items from Object Library ( Shift + Command + L ).

First add a Container View.

Continue building UI with following items:

  • Stack View – type horizontal, put on top and stretch horizontally
  • Cancel button – put to top Horizontal Stack View
  • Share button – put to top Horizontal Stack View
  • Image View – below Horizontal View
  • Picker View – below Image View
  • Text Input – to 'name’ the shared image

After adding all elements Share View Controller Scene should look like this.

Now we have to add handling the actions from buttons, setting Image and feed picker.

Note: if you prefer, you can also define a UI using SwiftUI instead of Storyboard

UIImageView

First let’s bring a shared image into the new modal. Open Storyboard in assistant mode and drag & drop UIImageView (while holding control) into ShareViewController. It should add a property into ShareViewController of type UIImageView. In my case I called the property SharedImage.

After that add a loadImage method to load UIImage and set it in UIImageView

// ShareViewcontroller

 override func viewDidLoad() {
    super.viewDidLoad()
    
    loadImage()
 }

 func loadImage() {
        let attachments = (self.extensionContext?.inputItems.first as? NSExtensionItem)?.attachments ?? []
        let imageType = UTType.jpeg.identifier as String

        for provider in attachments {
            if provider.hasItemConformingToTypeIdentifier(imageType) {
                provider.loadItem(forTypeIdentifier: imageType,
                                  options: nil) { (data, error) in
                    guard error == nil else { return }
                    
                    if let imageURL = data as? NSURL {
                        self.SharedImage.image = UIImage(contentsOfFile: imageURL.path!)
                    }
                }
            }
        }
    }

Note: to keep things simple, the code above only supports jpeg images, but you can use any other format as well as videos, urls or files in your own controller.

UIPickerView

Now this one is the real fun. Start by drag&drop from Storyboard to ShareViewController. We need some data to feed it with. For that let’s create a similar class to ShareDataManage, this time call it CategoriesManager. Add it to Shared folder and make available for both App and Share extension. We will use simple json to store values which are saved in App Group so that the data will be accessible from Share Extension and from App. Additionally we would add a plugin to add & remove a record in Categories.

// CategoriesManager.swift

import Foundation

class Categories: Encodable, Decodable{
    var items: [Category]
    
    init() {
        items = []
    }
    
    init(val: [Category]){
        self.items = val
    }
}

class Category: Encodable, Decodable {
    var id: Int
    var text: String
    
    init(id: Int, text: String) {
        self.text = text
        self.id = id
    }
}

class CategoriesManager {
    let file = "categories.json"
    
    fileprivate func sharedContainerURL() -> URL? {
        let groupURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "YOUR_APP_GROUP_NAME")
        return groupURL
    }
    
    func get() -> Categories {
        if let dir = sharedContainerURL()  {
            let filename = dir.appendingPathComponent(file)
            do {
                let data = try Data(contentsOf: filename)
                let decoder = JSONDecoder()
                let jsonData = try decoder.decode(Categories.self, from: data)
                return jsonData
            } catch {
                print("error:\(error)")
                
                return Categories()
            }
        }
        return Categories()
    }
    
    func add(text: String) {
        do {
            if let dir = self.sharedContainerURL()  {
                let jsonURL = dir.appendingPathComponent(file)
                let encoder = JSONEncoder()
                let data = self.get()
                let lastId = data.items.sorted(by: { l, r in
                    return l.id > r.id
                }).first?.id ?? 0
                
                let item = Category(id: lastId + 1, text: text)
                
                
                data.items.append(item)
                let json = try encoder.encode(data)
                try json.write(to: jsonURL)
            }
        }
        catch {
            print("error:\(error)")
        }
    }
    
    func remove(id: Int){
        do {
            if let dir = self.sharedContainerURL()  {
                let jsonURL = dir.appendingPathComponent(file)
                let encoder = JSONEncoder()
                let data = self.get()
                
                data.items = data.items.filter({ c in
                    return c.id != id
                })
                
                let json = try encoder.encode(data)
                try json.write(to: jsonURL)
            }
        }
        catch {
            print("error:\(error)")
        }
    }
}

Now let’s add Capacitor plugin to access that data.

// CategoriesPlugin.m
#import <Capacitor/Capacitor.h>

CAP_PLUGIN(CategoriesManagerPlugin, "CategoriesManagerPlugin",
           CAP_PLUGIN_METHOD(add, CAPPluginReturnNone);
           CAP_PLUGIN_METHOD(get, CAPPluginReturnPromise);
           CAP_PLUGIN_METHOD(remove, CAPPluginReturnNone);
)

&

// CategoriesPlugin.swift

import Capacitor


@objc(CategoriesManagerPlugin)
public class CategoriesManagerPlugin: CAPPlugin {
    @objc func add(_ call: CAPPluginCall) {
        let cm = CategoriesManager()
        
        let text = call.getString("text")!
        cm.add(text: text)
        call.resolve()
    }
    
    @objc func get(_ call: CAPPluginCall) {
        let cm = CategoriesManager()
        let data = cm.get()
        var result:[Any] = []
        
        for item in data.items {
            let itemDict: [String: Any] = [
                "text": item.text,
                "id": item.id
            ]
            result.append(itemDict)
        }
        
        call.resolve(["items": result])
    }
    
    @objc func remove(_ call: CAPPluginCall) {
        let cm = CategoriesManager()
        
        let id = call.getInt("id")!
        cm.remove(id: id)
        call.resolve()
    }
}

Now, we need some simple UI in the app to add the categories. Let’s just add a list with remove button and an input text to add a button.

// categories-manager-plugin.service.ts
import { Injectable } from '@angular/core';
import { Capacitor, registerPlugin } from '@capacitor/core';

const _pluginName: string = 'CategoriesManagerPlugin';

export class Categories {
  items: Category[] = [];
}
export class Category {
  id: number = -1;
  text: string = '';
}

export interface CategoriesManagerPlugin {
  add(category: { text: string }): Promise<void>;
  get(): Promise<Categories>;
  remove(category: { id: number }): Promise<void>;
}
const CategoriesManagerPlugin =
  registerPlugin<CategoriesManagerPlugin>(_pluginName);

@Injectable({
  providedIn: 'root',
})
export class CategoriesManagerPluginService {
  async add(text: string): Promise<void> {
    if (Capacitor.isPluginAvailable(_pluginName)) {
      return await CategoriesManagerPlugin.add({ text });
    }
  }

  async read(): Promise<Category[] | []> {
    if (Capacitor.isPluginAvailable(_pluginName)) {
      return (await CategoriesManagerPlugin.get())?.items ?? [];
    }
    return [];
  }
  async remove(id: number): Promise<void> {
    if (Capacitor.isPluginAvailable(_pluginName)) {
      await CategoriesManagerPlugin.remove({ id });
    }
  }
}

&

<!-- categories.component.html -->
<ion-item>
  <ion-label>Add category</ion-label>
  <ion-input [(ngModel)]="categoryName"></ion-input>
  <ion-button (click)="add()">Add</ion-button>
</ion-item>

<ion-list>
  <ion-item *ngFor="let category of categories">
    {{category.text}} <ion-button (click)="remove(category.id)">Remove</ion-button>
  </ion-item>
</ion-list>

&

// categories.component.ts
import { Component } from '@angular/core';
import {
  CategoriesManagerPluginService,
  Category,
} from 'src/app/services/categories-manager-plugin.service';

@Component({
  templateUrl: 'categories.component.html',
  selector: 'rd-categories',
})
export class CategoriesComponent {
  categories: Category[] = [];
  categoryName = '';
  constructor(private categoriesManager: CategoriesManagerPluginService) {}

  async ngOnInit() {
    await this.load();
  }

  async add() {
    if (!this.categoryName || this.categoryName.length < 1) {
      return;
    }
    await this.categoriesManager.add(this.categoryName);
    await this.load();
    this.categoryName = '';
  }

  async load() {
    this.categories = await this.categoriesManager.read();
  }

  async remove(id: number) {
    await this.categoriesManager.remove(id);
    await this.load();
  }
}

Ok. Now we have a way to save categories in the App Group which is shared across App & Share Extension. We can use it to feed the picker. Let’s go back to ShareViewController and add a method feedPicker() and implement protocols UIPickerViewDataSource and UIPickerViewDelegate

// ShareViewcOntroller.swift

class ShareViewController: UIViewController, UIPickerViewDataSource, UIPickerViewDelegate {

// ...
override func viewDidLoad() {
  super.viewDidLoad()
  
  loadImage()
  feedPicker()
}

// ....

func feedPicker() {
    let cm = CategoriesManager()
    categoriesSource = cm.get().items
    Categories.dataSource = self
    Categories.delegate = self
}

func numberOfComponents(in pickerView: UIPickerView) -> Int {return 1}
func pickerView(_ pickerView: UIPickerView, numberOfRowsInComponent component: Int) -> Int  {return categoriesSource.count}
func pickerView(_ pickerView: UIPickerView, titleForRow row: Int, forComponent component: Int) -> String? { return categoriesSource[row].text}

Take a break and give it a try now. Create some categories and then share some image. You should be able to select within a picker from the categories you created within Ionic app.

Buttons & TextInput

Finally let’s take care of what has left over. Drag&Drop using assistant CancelButton, ShareButton and TextInput. Let’s start from simplest which is CancelButton.

First we’re gonna add click handlers for buttons.

// ShareViewController.swift
override func viewDidLoad() {
    super.viewDidLoad()
    
    loadImage()
    feedPicker()
    ShareButton.addTarget(self, action: #selector(didShareButtonClick), for: .touchUpInside)
    CancelButton.addTarget(self, action: #selector(didCancelButtonClick), for: .touchUpInside)
}
    
@objc func didCancelButtonClick(_ sender: UIButton) {
    self.extensionContext?.cancelRequest(withError: CancellationError())
}

@objc func didShareButtonClick(_ sender: UIButton) {

}

CancelButton will just cancel share request and close the dialog. In case of share button we will mostly reuse logic from my previous post. The only difference is that we will expand the ShareDataItem with CategoryId so we make some example usage out of UIPickerView.

Make changes in ShareDataManager

//ShareDataManager.swift
class ShareDataItem: Encodable, Decodable {
    var text: String
    var image: String
    var categoryId: Int
    
    init(text: String, image: String, categoryId: Int) {
        self.text = text
        self.image = image
        self.categoryId = categoryId
    }
}

and now we can update didShareButtonClick method

//ShareViewController.swift
    @objc func didShareButtonClick(_ sender: UIButton) {
        let categoryId = self.categoriesSource[self.Categories.selectedRow(inComponent: 0)].id
        let sdm = ShareDataManager()
        let uiImage = self.SharedImage.image!
        let filmeManager = FileManager()
        let data = uiImage.jpegData(compressionQuality: 1.0)?.base64EncodedString() ?? ""
        
        sdm.write(image: "data:image/jpeg;base64,\(data)", text: self.TextInput.text!, categoryId: categoryId)
        self.extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
    }

On a side note: you quicky reach a memory limit while putting many files as base64 into json, but I used it since it’s a quick solution. Do NOT use it in real apps.

Back to the business – now all saved images will be attached to a selected category. Ionic part of the app also needs some updates. First let’s add categoryId to plugin result

// ShareExtensionDataPlugin.swift
@objc func read(_ call: CAPPluginCall) {
    let sdm = ShareDataManager()
    let data = sdm.read()
    var result:[Any] = []
    
    for item in data.items {
        let itemDict: [String: Any] = [
            "text": item.text,
            "image": item.image,
            "categoryId": item.categoryId
        ]
        result.append(itemDict)
    }
    
    call.resolve(["items": result])
}

And update UI

<!-- category.html -->
<ion-item>
  <ion-label>Add category</ion-label>
  <ion-input [(ngModel)]="categoryName"></ion-input>
  <ion-button (click)="add()">Add</ion-button>
</ion-item>

<ion-list>
  <ion-item *ngFor="let category of categories">
    {{category.text}} <ion-button (click)="remove(category.id)">Remove</ion-button>
    <div *ngFor="let item of sharedItems | inCategory:category.id">
      <h3>{{item.text}}</h3>
      <img [src]="item.image"/>
    </div>
  </ion-item>
</ion-list>
// category.component.ts
import {
  ShareDataItem,
  ShareExtensionDataPluginService,
} from './../../services/share-extension-data-plugin.service';
import { Component } from '@angular/core';
import {
  CategoriesManagerPluginService,
  Category,
} from 'src/app/services/categories-manager-plugin.service';

@Component({
  templateUrl: 'categories.component.html',
  selector: 'rd-categories',
})
export class CategoriesComponent {
  categories: Category[] = [];
  sharedItems: ShareDataItem[] = [];
  categoryName = '';
  constructor(
    private categoriesManager: CategoriesManagerPluginService,
    private shareExtensionDataPluginService: ShareExtensionDataPluginService
  ) {}

  async ngOnInit() {
    await this.load();
  }

  async add() {
    if (!this.categoryName || this.categoryName.length < 1) {
      return;
    }
    await this.categoriesManager.add(this.categoryName);
    await this.load();
    this.categoryName = '';
  }

  async load() {
    this.categories = await this.categoriesManager.read();
    this.sharedItems =
      (await this.shareExtensionDataPluginService.read()) ?? [];
  }

  async remove(id: number) {
    await this.categoriesManager.remove(id);
    await this.load();
  }
}
//in-category.pipe.ts
import { ShareDataItem } from './../services/share-extension-data-plugin.service';
import { Pipe, PipeTransform } from '@angular/core';

@Pipe({
  name: 'inCategory',
})
export class InCategoryPipe implements PipeTransform {
  transform(items: ShareDataItem[], categoryId: number) {
    return items?.filter((i) => i && i.categoryId === categoryId);
  }
}

Now below the categories, you will find the shared images.

Summary

It seems there’s no problem in adding Extensions to the Ionic based apps. Using the Capacitor plugins it is easy to set up communication between the App and Ionic environment, which makes all iOS platform features available.

App Groups can be used to make a connection between the App and the Extensions. It’s straightforward to use and work well with Ionic based app as well.

If you are building apps using Cordova not Capacitor you make find it problematic after adding / deleting plugins. Cordova project will drop any projects added along development. The quickest fix is to keep copy of pbxproj file and add some scripts to regenerate the projects from it.


Opublikowano

w

, ,

przez

Komentarze

Jedna odpowiedź do „Sharing content to your app – iOS custom view”

  1. […] This time let’s take a look how to receive data into Android application written on the Capacitor. This post is post #3 of series around sharing content. You can find previous post and read how to share a post to iOS and how to customize share view in iOS. […]