Things take time

[SWIFT] 공유하기! 방법 => Action Extension 본문

iOS (기능)

[SWIFT] 공유하기! 방법 => Action Extension

겸손할 겸 2018. 2. 1. 17:19

[개요]


인터넷이나 다른 앱에서 등 다양한 데이터를 공유하려 했을 때 호출되는 팝업, 아래와 같은 팝업을 봤을 것이다.



안드로이드에서는 intent filter라는 것을 이용해서, 해당 데이터타입이 공유하기를 통해 노출되면 자기 앱이 노출되도록 하는 방법이 있다.

그렇다면 iOS에서는 어떤 방법으로 노출되게 할 것인지에 대해 공부하려 한다.


노출되기 전에, 공유하기 버튼을 눌렀을 때 호출되는 함수 및 포스팅은 아래를 참고한다.


http://g-y-e-o-m.tistory.com/18


[Action Extension vs Share Extension]


참고 사이트

1. 기본 개념 (https://www.letmecompile.com/extensions-for-macos-10-10-ios-8/)

2. App Groups (https://medium.com/@flatcherlynd/ios-%EC%95%B1%EA%B3%BC-extension%EA%B3%BC%EC%9D%98-%EB%8D%B0%EC%9D%B4%ED%84%B0-%EA%B5%90%ED%99%98-3893b0fdf10e)

3. Action Extension (https://medium.com/@ales.musto/simple-text-action-extension-swift-3-c1ffaf3a197d)

4. Share Extension (http://www.yudiz.com/share-extention-in-ios-app/)


위의 스크린샷을 보자. 카카오톡이 위에 있고, Action이라는 아이콘이 밑에 있다. 여기서 작성한 예제는 Action을 가리키는데, 이는 Action Extension을 의미하고, 카카오톡은 Share Extension을 의미한다. 사용 목적에 맞게 사용하면 된다. (카카오톡은 액션 익스텐션 같은데.. 왜 위에 있지)


간략히 말해서 Extension은 앱 패키지 안에 들어있지만, 앱과는 독립적인 객체의 개념이다. 그리고 앱 <-> 앱 이동이 아니라 앱 <-> 익스텐션으로의 이동이라 생각하면 된다.


기본적인 앱 안에 들어있기에, 설치/삭제는 함께되지만, 동작은 별개이다.

간단한 예시로 위의 스크린샷에서 카카오톡을 눌러보면, 카카오톡 앱이 켜지는 것 같지만, 실상은 카카오톡 앱 안에 있는 익스텐션이 열려버린다. 그러므로 작업 후엔 카카오톡에서 계속 이용할 수 있는 개념이 아니고 카카오톡 익스텐션은 종료되고, 원래 호출했던 앱으로 돌아오게 되는 개념이다.



익스텐션은 공유(Share)와 액션(Action)을 제외하고도 여러 개가 있지만 이 두개에 대해서만 간략히 소개한다.

두 개 모두다 새로운 타겟으로 하나의 스토리보드와 하나의 뷰 컨트롤러가 제공된다. 그러므로 공유를 선택하면 공유 전용 뷰컨트롤러가 열리는 개념이다.


앞서 말했듯이, 익스텐션은 익스텐션을 가진 앱과는 별개의 개념이다. 그런데 만약 데이터를 공유해야 한다면, 이를 지원하는 것이 App Groups이며 App Groups가 같이 선택되어 있다면 앱과 익스텐션간의 데이터 교환이 가능하다. 폰 DB 개념인 UserDefault와 파일 단위의 FileManager를 통한 접근이 가능하다.



[예제]


이번 포스팅의 예제는 두 익스텐션중 액션 익스텐션에 대해서 포스팅한다.


2개의 프로젝트가 있다. 

하나는 익스텐션을 포함하는 앱을 호출 및 리턴받는 앱이며, 하나는 익스텐션을 갖고 있는 앱이다.


호출하는 앱 : 프로젝트를 생성하고, 아래와 같은 스토리보드와 코드를 입력한다. 텍스트 뷰와 버튼에 각각 아울렛과 함수를 연결한다.



[코드]

import UIKit
import MobileCoreServices

class ViewController: UIViewController {

    @IBOutlet weak var textView: UITextView!
    override func viewDidLoad() {
        super.viewDidLoad()
        // Do any additional setup after loading the view, typically from a nib.
    }
    @IBAction func btnClicked(_ sender: UIButton) { var objectsToShare = [String]()
        
        if let text = textView.text {
            objectsToShare.append(text)
        }
        let activityVC = UIActivityViewController(activityItems: objectsToShare, applicationActivities: nil)
        activityVC.excludedActivityTypes = [UIActivityType.airDrop, UIActivityType.addToReadingList]
        self.present(activityVC, animated: true, completion: nil)
        
        activityVC.completionWithItemsHandler =
            { (activityType, completed, returnedItems, error) in
                
                if returnedItems!.count > 0 {
                    
                    let textItem: NSExtensionItem =
                        returnedItems![0] as! NSExtensionItem
                    
                    let textItemProvider =
                        textItem.attachments![0] as! NSItemProvider
                    
                    if textItemProvider.hasItemConformingToTypeIdentifier(
                        kUTTypeText as String) {
                        
                        textItemProvider.loadItem(
                            forTypeIdentifier: kUTTypeText as String,
                            options: nil,
                            completionHandler: {(string, error) -> Void in
                                let newtext = string as! String
                                DispatchQueue.main.async {
                                    self.textView.text = newtext
                                }
                        })
                    }
                }
        }
    }
}

소스를 간단히 분석하면, 텍스트뷰에 있는 텍스트 데이터값을 UIActivityViewController에 호출함과 동시에 같이 넣어 보낸다. 그리고 호출된 UIActivityViewController는 해당 데이터를 받을 수 있는 앱을 모바일 디바이스 화면 아래에서 호출한다. 


그리고 UIActivityViewController의 completionWithItemsHandler를 통해 해당 익스텐션에서 작업 후에 리턴받는 값을 얻어와 다시 또 텍스트 뷰에 뿌릴 수 있도록 되어 있다. Completion Handler의 파라미터를 갖고 하는 것은 문법처럼 사용되니, 가져다 쓰면 된다.


2. 호출받는 앱


앱에서는 두 개의 타겟을 생성한다.




여기서 타겟을 열어 보면 여러 익스텐션이 존재한다. 여기서 사용할 것인 왼쪽 위의 Action과 오른쪽 아래의 Share를 각각 이름을 지정해서 생성한다.



이처럼 두 개의 타겟이 생성되었고, 각각의 익스텐션에는 뷰컨트롤러와 스리보드가 있다.

여기서 먼저 Action에 대해 작업하려한다.





기본 템플릿에는 이미지뷰 하나와 Done 버튼 하나가 제공되는데 여기서 이미지뷰를 텍스트 뷰로 교체한다. 


이제 한 가지로 알아야 할 것이 있다. 각 익스텐션안에는 plist파일이 있는데, 그 안에 있는 NSExtension 딕셔너리 안에 있는 값들이다. 기본적으로 위에 까지 세팅을하게 되면, 공유하기를 눌렀을 때 하단에는 내가 지정한 Action, Share 익스텐션 두개가 나란히 나오게 된다 (위, 아래로).


그런데 만약, 내가 설정한 익스텐션은 특정 데이터에만 공유하기가 노출되게 하고싶다면, 예를 들어 텍스트, 이미지에 공유하기를 눌렀을 때만 호출되게 하고 싶다면 이 plist값을 수정해야한다. 그리고 리젝 사유에도 중요하게 들어간다고 하니 꼭 봐두자.


String값으로 되어있던 ActivationRule의 타입을 딕셔너리로 수정하고, 그 안에 들어가는 키 값들을 원하는 것에 맞춰 수정한다. 위처럼 NO로 하면 텍스트를 누른채 공유하기를 누르면 해당 익스텐션은 노출되지 않는다. 예제는 노출되게 해야 하므로 YES로 수정한다.


해당 키 값에 대한 도큐먼트

https://developer.apple.com/library/content/documentation/General/Reference/InfoPlistKeyReference/Articles/AppExtensionKeys.html


안드로이드의 인텐트 필터와 비슷한 기능으로 보면 되겠다. 안드로이드는 해당 필터가 걸리면 앱을 노출시키는 것이고, iOS는 선택한다는 차이 정도?


[코드]

import UIKit
import MobileCoreServices

class ActionViewController: UIViewController {

    @IBOutlet weak var textView: UITextView!
    var convertedString: String?

    override func viewDidLoad() {
        super.viewDidLoad()
    
        
        // Get the item[s] we're handling from the extension context.
        let textItem = self.extensionContext!.inputItems[0] as! NSExtensionItem
        
        let textItemProvider = textItem.attachments![0] as! NSItemProvider
        
        if textItemProvider.hasItemConformingToTypeIdentifier(kUTTypeText as String) {
            textItemProvider.loadItem(forTypeIdentifier: kUTTypeText as String,
                                      options: nil,
                                      completionHandler: { (result, error) in
                                        self.convertedString = result as? String
                                        if self.convertedString != nil {
                                            self.convertedString = self.convertedString!.appending(".")
                                            DispatchQueue.main.async {
                                                self.textView.text = self.convertedString!
                                                print("result")

                                            }
                                        }
                                        
                                        
            })
        }
        /* 이미지 뷰 샘플 코드
        // For example, look for an image and place it into an image view.
        // Replace this with something appropriate for the type[s] your extension supports.
        var imageFound = false
        for item in self.extensionContext!.inputItems as! [NSExtensionItem] {
            for provider in item.attachments! as! [NSItemProvider] {
                if provider.hasItemConformingToTypeIdentifier(kUTTypeImage as String) {
                    // This is an image. We'll load it, then place it in our image view.
                    weak var weakImageView = self.imageView
                    provider.loadItem(forTypeIdentifier: kUTTypeImage as String, options: nil, completionHandler: { (imageURL, error) in
                        OperationQueue.main.addOperation {
                            if let strongImageView = weakImageView {
                                if let imageURL = imageURL as? URL {
                                    strongImageView.image = UIImage(data: try! Data(contentsOf: imageURL))
                                }
                            }
                        }
                    })
                    
                    imageFound = true
                    break
                }
            }
            
            if (imageFound) {
                // We only handle one image, so stop looking for more.
                break
            }
        }
        */
    }

    @IBAction func done() {
        // Return any edited content to the host app.
        // This template doesn't do anything, so we just echo the passed in items.
        /*
        self.extensionContext!.completeRequest(returningItems: self.extensionContext!.inputItems, completionHandler: nil)
        */
        
        let returnProvider =
            NSItemProvider(item: convertedString as NSSecureCoding?,
                           typeIdentifier: kUTTypeText as String)
        
        let returnItem = NSExtensionItem()
        
        returnItem.attachments = [returnProvider]
        self.extensionContext!.completeRequest(
            returningItems: [returnItem], completionHandler: nil)
 
    }
}

이미지 뷰 샘플코드는 이미 먼저 생성되어있던 이미지 뷰가 있을 때 템플릿을 제공된 코드다. 이 코드도 참고해서 보면 되겠다.

익스텐션이 켜지게 되면 extensionContext라는 변수(UIViewController에 상속받아지는 변수)에서 아이템을 접근하고, 들어온 값들을 사용할 수 있다. 이 값들을 익스텐션의 텍스트 뷰에 뿌리돼, 끝에 . 하나를 써서 띄우겠다는 것이다.


그리고 done이란 함수도 기본제공인데, 스토리보드의 Done을 눌렀을 때 발동되는 함수이며, ExtensionItem 객체를 리턴하면서 현재 익스텐션을 종료 및 호출한 앱에 데이터를 전달하며 마무리 된다.


여기서 나오는 사용법등은 문법적으로 쓰이는 것이기 때문에, 이해보다는 템플릿으로 두는 것이 낫겠다.


두 앱을 실행하고, 1에서 만든 호출하는 앱에서 버튼을 눌러보자.

1에서 만든 앱은 이렇게 생겼다. abcde란 값을 텍스트뷰에, 버튼에는 UIActivityController를 호출하는 데 텍스트 값(abcde)을 같이 전달한다.



그렇게 되면 두 개의 익스텐션(Share, Action)이 나오게 되고 여기서 Action을 클릭



그리고 익스텐션에서는 넘겨받은 텍스트 값에 + '.'을찍어 텍스트 뷰에 보여준다.

그리고 Done을 누르게 되면, abcde. 이란 값을 원래의 앱에 리턴한다.


그리고 원래의 앱으로 돌아오면 abcde란 값이 abcde.가 된 것으로 확인할 수 있다.