[iOS] Share Extension (2)

Picnic·2024년 12월 10일

iOS

목록 보기
2/5
post-thumbnail

안녕하세요 Picnic🧃입니다.

이번엔 지난 포스팅에 이어 Share Extension에 대해 계속 알아보도록 하겠습니다.

먼저 공식문서에 나와있는 글을 살펴보겠습니다.

호스트 앱의 요청에 응답하기


사용자가 호스트 앱 내에서 Extension을 선택하면 호스트 앱이 요청을 발행하고 App Extension이 열립니다. 높은 수준에서 보면, Extension은 요청을 받고, 사용자가 작업을 수행하도록 도우며, 사용자의 동작에 따라 요청을 완료하거나 취소합니다. 예를 들어, Share Extension은 호스트 앱으로부터 요청을 받아 뷰를 표시하고, 사용자가 콘텐츠를 작성한 후 이를 게시하거나 취소하는 선택에 따라 요청을 완료하거나 취소합니다.

예를 들어 유튜브에서 공유 버튼을 눌러 Extension을 선택한다면 Host App은 유튜브가 됩니다!

호스트 앱이 App Extension에 요청을 보낼 때 extension context를 지정합니다.

대부분의 확장에서 가장 중요한 context의 부분은 사용자가 extension에서 작업하고자 하는 item의 집합입니다.

호스트 앱이 요청을 발행하면(일반적으로 beginRequestWithExtensionContext: 메서드를 호출하여), App Extension은 기본 뷰 컨트롤러의 extensionContext 프로퍼티를 사용해 컨텍스트를 가져올 수 있습니다. 자식 뷰 컨트롤러도 체인을 통해 이 속성에 접근할 수 있습니다.

이 프로퍼티는 UIViewController가 가지고 있네요!

다음으로, NSExtensionContext 클래스를 사용하여 컨텍스트를 확인하고 그 안에 있는 item을 가져옵니다.

보통 뷰 컨트롤러의 loadView 메서드에서 컨텍스트와 항목을 가져와 정보를 뷰에 표시하는 방식이 잘 동작합니다.

Extension의 컨텍스트를 가져오려면 다음과 같은 코드를 사용할 수 있습니다:

guard let extensionContext = extensionContext else { return }

특히 관심을 가져야 할 부분은 컨텍스트 객체의 inputItems 프로퍼티입니다.

이 프로퍼티는 Extension이 사용해야 할 항목들을 포함할 수 있습니다.

inputItems 프로퍼티는 Extension이 작업할 수 있는 항목 각각을 포함하는 NSExtensionItem 객체의 배열입니다.
컨텍스트 객체에서 item을 가져오려면 다음과 같은 코드를 사용할 수 있습니다:

guard let extensionItems = extensionContext.inputItems as? [NSExtensionItem] else { return }

inputItems는 [Any] 타입이므로 [NSExtension] 타입으로 타입캐스팅을 해줘야 합니다.


각 NSExtensionItem 객체에는 item의 제목, 콘텐츠 텍스트, 첨부 파일, 사용자 정보(user info) 등 item의 여러 측면을 설명하는 여러 속성이 포함됩니다.

특히 attachments 속성은 item과 연관된 미디어 데이터를 포함하는 배열입니다.
예를 들어, 공유 요청과 연관된 항목에서는 attachments 속성이 사용자가 공유하려는 웹페이지의 표현을 포함할 수 있습니다.

사용자가 inputItem으로 작업한 후(Extension을 사용하는 과정에 이 작업이 포함된 경우), App Extension은 일반적으로 사용자가 작업을 완료하거나 취소하는 선택을 하도록 합니다.

사용자의 선택에 따라 completeRequestReturningItems:completionHandler: 메서드를 호출하여 NSExtensionItem 객체를 호스트 앱에 반환하거나, cancelRequestWithError: 메서드를 호출하여 오류 코드를 반환합니다.

중요
App Extension이 completeRequestReturningItems:completionHandler: 메서드를 호출할 경우, 시스템에서 요청할 경우 앱 확장을 최소한 일시 중단할 수 있도록 completionHandler 블록을 제공해야 합니다. 이 메서드의 completionHandler 블록에 대한 자세한 내용은 이 문서를 참고하세요.



커스텀 뷰 만들기


ContextItem을 살펴보기 전에 먼저 커스텀 뷰를 만드는 법을 알아보겠습니다.

CustomView를 만드려면 SLComposeServiceViewController를 상속하고 있던 것을 UIViewController로 바꿔주기만 하면 됩니다!

그리고 원래 코드로 뷰를 작성하던 방식으로 뷰를 만들어주면 됩니다.

예를 들어 다음과 같이 말이죠

import UIKit
import Social

class ShareViewController: UIViewController {
    private var navigationBar: UINavigationBar!
    private var itemLabel: UILabel!

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

extension ShareViewController {
    private func configureView() {
        view.backgroundColor = .systemBackground
        configureNavBar()
        configureItemLabel()
    }
    
    private func configureNavBar() {
        navigationBar = UINavigationBar()
        navigationBar.translatesAutoresizingMaskIntoConstraints = false
        
        let navItem = UINavigationItem(title: "ShareExtenion 연습")
        navItem.leftBarButtonItem = UIBarButtonItem(systemItem: .cancel)
        navItem.rightBarButtonItem = UIBarButtonItem(systemItem: .done)
        navigationBar.setItems([navItem], animated: false)
        
        view.addSubview(navigationBar)
        
        NSLayoutConstraint.activate([
            navigationBar.topAnchor.constraint(equalTo: view.topAnchor),
            navigationBar.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            navigationBar.trailingAnchor.constraint(equalTo: view.trailingAnchor)
        ])
    }
    
    private func configureItemLabel() {
        itemLabel = UILabel()
        itemLabel.translatesAutoresizingMaskIntoConstraints = false
        itemLabel.backgroundColor = .black
        itemLabel.textColor = .white
        itemLabel.text = "Hello Share Extension"
        
        view.addSubview(itemLabel)
        
        NSLayoutConstraint.activate([
            itemLabel.widthAnchor.constraint(equalToConstant: 300),
            itemLabel.heightAnchor.constraint(equalToConstant: 50),
            itemLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            itemLabel.centerYAnchor.constraint(equalTo: view.centerYAnchor)
        ])
    }
}

그러면 위와 같이 화면이 나오는 것을 볼 수 있습니다.

Share Extension이 나타날 때에는 Modal 타입으로 뷰가 올라오게 됩니다.
기본 ComposeView를 보더라도 어두운 배경의 뷰가 뒤에 있는 것을 볼 수 있습니다.

커스텀 뷰를 만드는 법을 보았으니 이제 진짜 데이터를 얻는 법을 보겠습니다!


ContextItem 얻기


위의 뷰를 만든 이유는 Host App에서 받은 정보를 화면에 띄우기 위함이었습니다.

그러면 Safari에서 애플 홈페이지에 들어가 해당 페이지의 텍스트를 얻어볼게요!

텍스트 얻기


class ShareViewController: UIViewController {
    private var navigationBar: UINavigationBar!
    private var itemLabel: UILabel!
    private var itemText: String?
    
    override func loadView() {
        super.loadView()
        guard let extensionContext = extensionContext else { return }
        guard let extensionItems = extensionContext.inputItems as? [NSExtensionItem] else { return }
        guard let extensionItem = extensionItems.first else { return }
        
        itemText = extensionItem.attributedContentText?.string
    }
    
    override func viewDidLoad() {
        super.viewDidLoad()
        configureView()
        itemLabel.text = itemText
    }
    
}

앞의 코드에서 itemText 변수를 추가하고 loadView에서 Item을 얻어줍니다.

그리고 itemText에 attributedContentText?.string을 한 번 출력해보면

다음과 같이 Apple(대한민국)이 나오는 것을 볼 수 있습니다!

이 문구는

이렇게 처음 ComposeView를 사용했을 때 기본적으로 나온 텍스트와 같은 것을 볼 수 있죠!


링크 얻기


그러면 이번엔 제가 프로젝트에서 사용했던 방식인 유튜브의 링크를 얻어보겠습니다.

먼저 이전의 코드로 유튜브에 들어가 영상에서 공유를 눌러 Extension을 열어보면 아무것도 나오지 않습니다.

유튜브 영상의 경우는 ExtensionItem의 attachments 프로퍼티를 봐야 합니다.

위에서 특히 attachments 속성은 item과 연관된 미디어 데이터를 포함하는 배열입니다. 라는 글을 봤었죠.

그래서 이번엔 attachments를 print해보면

이렇게 NSItemProvider 타입의 아이템이 배열에 하나 들어가 있는 것을 알 수 있습니다.

그런데 NSItemProvider가 뭘까요?

NSItemProvider


애플 개발자 문서를 보면 NSItemProvider는 다음과 같이 나와있습니다.

드래그 앤 드롭 또는 복사 및 붙여넣기 활동 중 또는 호스트 앱에서 앱 확장으로 프로세스 간에 데이터 또는 파일을 전달하기 위한 항목 제공자.

그리고 아래에는 저희의 관심사인 App extension support가 있네요!

App Extension은 일반적으로 NSExtensionItem 객체의 attachment 속성을 검사할 때 ItemProvider를 만나게 됩니다. (저희가 봤던 대로네요😎)

그 검사 중에, Extension은 hasItemConformingToTypeIdentifier(_:) 메서드를 사용하여 인식하는 데이터를 찾을 수 있습니다.

ItemProvider는 그들이 포함하는 데이터를 식별하기 위해 균일한 유형 식별자 값을 사용합니다.
확장이 사용할 수 있는 데이터 유형을 찾은 후, loadItem(forTypeIdentifier:options:completionHandler:) 메서드를 호출하여 실제 데이터를 로드하고, 이는 제공된 완료 핸들러로 전달됩니다.

데이터를 다른 프로세스로 판매(vend)하기 위해 ItemProvider를 만들 수 있습니다. 원본 데이터 item을 수정하는 Extension은 호스트 앱으로 다시 보낼 새로운 NSItemProvider 객체를 생성할 수 있습니다. 데이터 항목을 만들 때 데이터 개체와 해당 개체의 유형을 지정합니다. 선택적으로 previewImageHandler 속성을 사용하여 데이터에 대한 미리보기 이미지를 생성할 수 있습니다.

단일 항목 제공자는 사용자 지정 블록을 사용하여 다양한 형식으로 데이터를 제공할 수 있습니다. ItemProvider를 구성할 때, registerItem(forTypeIdentifier:loadHandler:) 방법을 사용하여 블록과 각각이 지원하는 형식을 등록하십시오. 클라이언트가 특정 형식으로 데이터를 요청할 때, 항목 제공자는 해당 블록을 실행하며, 해당 블록은 데이터를 적절한 유형으로 강제하고 클라이언트에게 반환할 책임이 있습니다.

이번에 저희는 회색부분은 제외하고 보겠습니다.

일단 hasItemConformingToTypeIdentifier(_:) 를 사용하기 위해 균일한 유형 식별자 값을 알아야겠죠

그러데 이 균일한 유형 식별자는 또 뭘까요..?

Uniform Type Identifiers


바로 번역기를 돌려보겠습니다…😡

Uniform Type Identifiers 프레임워크는 MIME 및 파일 유형에 매핑되는 일반적인 유형의 모음을 제공합니다. 프로젝트에서 이러한 유형을 사용하여 앱의 파일 유형을 설명하십시오. 이러한 설명은 시스템이 전송을 위해 파일 저장 형식 또는 메모리 내 데이터를 올바르게 처리하는 데 도움이 됩니다. 예를 들어, 데이터를 붙여넣기 보드로 또는 붙여넣기에서 데이터를 전송합니다. 식별자 유형은 디렉토리, 볼륨 또는 패키지와 같은 다른 리소스도 식별할 수 있습니다.

다른 유형의 하위 유형으로 표시하여 유형 간의 관계를 명시적으로 지정하십시오. 예를 들어, UTTypePNG 유형은 식별자 public.png를 가지며 UTTypeImage(public.image)의 하위 유형입니다. UTTypeImage는 다음 두 가지 모두의 하위 유형입니다.

  • UTTypeContent (public.content), 이는 유형이 문서가 될 수 있음을 의미합니다.
  • UTTypeData (public.data), 이는 유형이 바이트 스트림으로 표현될 수 있음을 의미합니다.

일단 프레임워크라는 것을 알았고 파일을 식별하는 식별자라고 간단히 생각할 수 있을 것 같아요.

그리고 읽다보면 뭔가 낯익은 글자가 보이지 않나요?

public.content, public.image, public.data…

아까 ItemProvider를 보았을 때 나왔던 “public.url”!!

그렇다면 유튜브 동영상에서 얻은 ExtensionItem의 attachment에는 public.url 타입의 ItemProvider가 있다는 것을 알 수 있네요!

그러면 public.url과 매칭되는 어떤 타입을 찾으면 될 것 같아요.

https://developer.apple.com/documentation/uniformtypeidentifiers/uttype-swift.struct

이곳을 보면 UTType이 가지고 있는 변수들을 볼 수 있습니다.

여기에 url도 있으니 이를 이용해 ItemProvider를 사용해볼게요.

위에서 봤던 것처럼 hasItemConformingToTypeIdentifier(_:) 를 통해 데이터가 있는지 찾고 loadItem(forTypeIdentifier:options:completionHandler:) 를 통해 completionHandler로 받은 데이터를 사용하면 됩니다.

CompletionHandler는 다음과 같은 타입입니다.

typealias CompletionHandler = @Sendable ((any NSSecureCoding)?, (any Error)?) -> Void

타입을 보면 어려워 보이지만 설명을 보면 쉽게 이해할 수 있습니다!

item

적재할 항목. 블록을 지정할 때, 이 매개 변수의 유형을 원하는 특정 데이터 유형으로 설정하십시오. 예를 들어, 텍스트 데이터를 요청할 때, 유형을 NSString 또는 NSAttributedString으로 설정할 수 있습니다. 아이템 제공자는 당신이 지정한 클래스로 데이터를 강요하려고 시도합니다.

error

데이터를 로드할 때 발생한 문제에 대한 정보를 수신하기 위한 오류 객체에 대한 포인터.

즉 받은 아이템은 사용하려는 타입에 맞게 캐스팅해서 사용해라! 라는 말이죠.

override func loadView() {
    super.loadView()
    guard let extensionContext = extensionContext else { return }
    guard let extensionItems = extensionContext.inputItems as? [NSExtensionItem] else { return }
    guard let extensionItem = extensionItems.first else { return }
    guard let extensionItemProvider = extensionItem.attachments?.first else { return }
        
    if extensionItemProvider.hasItemConformingToTypeIdentifier(UTType.url.identifier) {
        extensionItemProvider.loadItem(forTypeIdentifier: UTType.url.identifier, options: nil) { [weak self] (url, error) in
            if let url = url as? URL {
                DispatchQueue.main.async {
                    self?.itemLabel.text = url.absoluteString
                }
            }
        }
    }
}

// loadView에서 itemLabel의 text를 변경하기 때문에 viewDidLoad()에서 itemLabel.text = itemText 삭제

그래서 다음과 같이 작성을 하면…!

ㄷㅗㅐㅆㄷㅏ…🥺

위와 같이 영상의 주소를 얻을 수 있습니다!

이건 프로젝트를 하면서 알게 된 사실인데 사파리를 통해 유튜브에 들어가서 공유를 하는 방식이라면 위와 같은 방법으로 하면 되지만 유튜브 앱에서 공유하기를 눌러서 링클르 얻을 때에는 UTType.url이 아니라 UTType.plainText를 통해 text를 얻어야 합니다.
참고: https://stackoverflow.com/questions/43954725/ios-youtube-share-extension-not-providing-url


마무리


이렇게 해서 Share Extension에 대해 알아봤습니다.

2편으로 끝낼줄 알았는데 생각보다 오래걸리네요😢

다음 편에서는 Share Extension에서 얻은 데이터를 main 앱에서 사용하는 방법에 대해 알아보도록 하겠습니다.🫠

참고


0개의 댓글