Project/개발 업무

iOS 공유하기 LPLinkMetadata (UIKit, SwiftUI) 디자인패턴 적용

lgvv 2021. 6. 7. 02:16

iOS 공유하기 LPLinkMetadata (UIKit, SwiftUI) 디자인패턴 적용

iOS 시스템 공유하기 기능을 개발

SwiftUI에서 지원하는 ShareLink도 있지만, 더 범용성있게 사용하기 위해 해당 형태로 구현

 

히스토리

  • 2021.06.17.: 초기버전
    • Swift 5, Xcode 12, iOS minimum Target 9.0
  • 2024.12.11: 개선버전
    • Swift 5.10, Xcode 16.1, minimum Target 13.0
    • 빌더 패턴 적용
    • Mixin and Trait 개념 적용
    • 모듈화
    • LinkMetaData 적용
  • 2026.07.24: 네이밍 개선버전
    • Swift 6
    • 역할이 이름에 드러나도록 리네임: SystemSharingFeature는 ShareSheetPresentable로, SharingFeatureBuilder는 ShareSheetBuilder로 변경
    • strict concurrency 대응으로 UI를 다루는 타입에 @MainActor 명시
  • 2026.07.26: 모듈 분리 버전
    • ShareSheetUI 별도 모듈(Swift Package)로 분리
    • 프로토콜 채택(Mixin) 제거. import ShareSheetUI 후 ShareSheet.show를 직접 호출하는 구조로 변경
    • 채택을 강제하지 않고, 능력의 출처가 호출부(import)에 드러나는 것이 장점
    • keyWindow, topMostViewController 헬퍼는 모듈 내부로 은닉

 

코드 구현에 사용된 개념

 

목차

  • 결과 스크린샷
  • 구현부 전체 코드
  • Usage
    • SwiftUI
    • UIKit + MVC
    • UIKit + MVVM

 

결과 스크린샷

액티비티 아이템에는 빌더 패턴을 통해 여러 값들을 넣어줄 수 있음. 시뮬레이터 예제로 실기기에서 테스트하면 더 다양한 옵션이 존재.

  • 왼쪽이미지
    • URL만 넣어준 경우 웹에서 세팅된 값을 자동으로 설정
  • 오른쪽 이미지
    • URL 뿐만아니라 썸네일, 타이틀 등 내가 필요한 것들을 명시적으로 세팅

스크린샷


구현부 전체 코드

구현에 대한 전체 코드. ShareSheetUI라는 별도 모듈(Swift Package)로 분리. 사용하는 쪽은 프로토콜 채택 없이 import ShareSheetUI 후 ShareSheet.show를 호출.

// MARK: - ShareSheet

/// 시스템 공유하기 진입점
///
/// 윈도우 최상단에 띄우는 방식이라 View가 없는 객체(예: RIBs의 Viewless RIB)에서도 사용 가능
public enum ShareSheet {
    @MainActor
    public static func show(_ activityViewController: UIActivityViewController) {
        if let keyWindow = UIWindow.keyWindow {
            let topViewController = keyWindow.rootViewController?.topMostViewController()
            topViewController?.present(activityViewController, animated: true, completion: nil)
        }
    }
}

// MARK: - UIWindow + keyWindow (모듈 내부 전용)

extension UIWindow {
    @MainActor
    static var keyWindow: UIWindow? {
        UIApplication.shared.connectedScenes
            .compactMap { $0 as? UIWindowScene }
            .flatMap { $0.windows }
            .first { $0.isKeyWindow }
    }
}

// MARK: - UIViewController + topMostViewController (모듈 내부 전용)

extension UIViewController {
    func topMostViewController() -> UIViewController {
        if let presented = presentedViewController {
            return presented.topMostViewController()
        }
        if let navigationController = self as? UINavigationController {
            return navigationController.visibleViewController?.topMostViewController() ?? navigationController
        }
        if let tabBarController = self as? UITabBarController {
            return tabBarController.selectedViewController?.topMostViewController() ?? tabBarController
        }
        return self
    }
}

// MARK: - ShareSheetBuildable

@MainActor
public protocol ShareSheetBuildable {
    func excludedActivityTypes(_ excludedActivityTypes: [UIActivity.ActivityType]) -> Self
    func activityItemSource(_ activityItemSource: ActivityItemSource) -> Self
    func activityItem(_ activityItems: [Any]) -> Self

    func build() -> UIActivityViewController
}

// MARK: - ShareSheetBuilder (Builder Pattern)

@MainActor
public final class ShareSheetBuilder: ShareSheetBuildable {
    var excludedActivityTypes: [UIActivity.ActivityType]?
    var activityItemSource: ActivityItemSource?
    var activityItems: [Any]?

    public init() {}

    public func excludedActivityTypes(_ excludedActivityTypes: [UIActivity.ActivityType]) -> Self {
        self.excludedActivityTypes = excludedActivityTypes
        return self
    }

    public func activityItemSource(_ activityItemSource: ActivityItemSource) -> Self {
        self.activityItemSource = activityItemSource
        return self
    }

    public func activityItem(_ activityItems: [Any]) -> Self {
        self.activityItems = activityItems
        return self
    }

    public func build() -> UIActivityViewController {
        let activityViewController = UIActivityViewController(
            activityItems: activityItems ?? [activityItemSource as Any],
            applicationActivities: nil
        )
        activityViewController.excludedActivityTypes = excludedActivityTypes
        return activityViewController
    }
}

// MARK: - ActivityItemSource (LPLinkMetadata)

/// 공유 시트에 노출될 미리보기(타이틀, 썸네일, URL)를 명시적으로 세팅하는 아이템 소스
public final class ActivityItemSource: NSObject, UIActivityItemSource {
    private let title: String
    private let url: URL?
    private let image: UIImage

    public init(title: String, urlString: String, image: UIImage) {
        self.title = title
        self.url = URL(string: urlString)
        self.image = image
    }

    public func activityViewControllerPlaceholderItem(
        _ activityViewController: UIActivityViewController
    ) -> Any {
        title
    }

    public func activityViewController(
        _ activityViewController: UIActivityViewController,
        itemForActivityType activityType: UIActivity.ActivityType?
    ) -> Any? {
        url ?? title
    }

    public func activityViewController(
        _ activityViewController: UIActivityViewController,
        subjectForActivityType activityType: UIActivity.ActivityType?
    ) -> String {
        title
    }

    public func activityViewControllerLinkMetadata(
        _ activityViewController: UIActivityViewController
    ) -> LPLinkMetadata? {
        let metadata = LPLinkMetadata()
        metadata.title = title
        metadata.originalURL = url
        metadata.url = url
        metadata.imageProvider = NSItemProvider(object: image)
        metadata.iconProvider = NSItemProvider(object: image)
        return metadata
    }
}

 

Usage

사용법 정리

  • SwiftUI
  • UIKit + MVC
  • UIKit + MVVM

빌더 패턴을 적용하여 필요한 것들을 붙여서 사용할 수 있음

  • 윈도우 위에 띄우는 구현 방식으로 View가 없는 객체에서도 사용할 수 있음
  • 즉, RIBs에서 View가 없는 RIB에서 해당 행위 호출 가능하다는 의미

빌더패턴

import ShareSheetUI

// MARK: - Usage (SwiftUI)
struct SwiftUIView: View {
    var body: some View {
        Button("공유하기 버튼 실행") {
            let builder = ShareSheetBuilder()
                .activityItemSource(.init(title: "당근", urlString: "https://rldd.tistory.com/720", image: .init()))
                .build()
            ShareSheet.show(builder)
        }
    }
}

// MARK: - Usage (UIKit - MVC)

final class ViewController_MVC: UIViewController {

    init() {
        super.init(nibName: nil, bundle: nil)

        configureUI()
    }

    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    private lazy var shareButton: UIBarButtonItem = {
        let btn = UIBarButtonItem(barButtonSystemItem: .play, target: self, action: #selector(didTapShareButton))
        return btn
    }()

    @objc private func didTapShareButton() {
        let builder = ShareSheetBuilder()
            .activityItemSource(.init(title: "당근", urlString: "https://rldd.tistory.com/720", image: .init()))
            .build()

        ShareSheet.show(builder)
    }

    private func configureUI() {
        navigationItem.rightBarButtonItem = shareButton
    }
}

// MARK: - Usage (UIKit - MVVM)

final class ViewModel {
    enum Action {
        case shareButtonTapped
    }

    func send(action: Action) {
        switch action {
        case .shareButtonTapped:
            let builder = ShareSheetBuilder()
                .activityItemSource(.init(title: "당근", urlString: "https://rldd.tistory.com/720", image: .init()))
                .build()

            ShareSheet.show(builder)
        }
    }
}

final class ViewController_MVVM: UIViewController {
    private let viewModel: ViewModel

    init() {
        self.viewModel = .init()
        super.init(nibName: nil, bundle: nil)

        configureUI()
    }

    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    private lazy var shareButton: UIBarButtonItem = {
        let btn = UIBarButtonItem(barButtonSystemItem: .play, target: self, action: #selector(didTapShareButton))
        return btn
    }()

    @objc private func didTapShareButton() {
        viewModel.send(action: .shareButtonTapped)
    }

    private func configureUI() {
        navigationItem.rightBarButtonItem = shareButton
    }
}