【SwiftUI】スクリーンショットを活用した画像生成

モバイルアプリの強みの一つは同じデバイスでSNSやメッセージアプリで手軽にコンテンツを共有できることです。そこで今回はアプリ内で少し複雑なシェア用の画像を生成する方法を紹介します。

ゲームのリザルト画面やクーポンなど様々なケースが考えられますが、実例として私が開発しているAIキャラ制作アプリ『Eveki』の場合を挙げて実装までを考えてみます。

# 実装したい機能

## ユーザー体験

『Eveki』ではユーザーが話し相手となるAIの設定を行うことできます。これに加えて、作ったAIをコンテンツとして共有できるようにしたいと考えています。具体的には、情報を反映させたカードを画像として生成し、友人に送信したりコレクションすることができるようにします。

![ユーザーが設定を入力 1280x670.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/713689/344c57dc-f201-7b96-3782-c66ec7462298.png)


## 課題の整理

画像生成というと難しく聞こえますが、まずは問題を分割してみましょう。コードの段階から画像を編集する操作は複雑になりそうです。しかし、ユーザーの入力に合わせて`View`の見た目を変化させることは簡単です。

そこでこのカード部分の`View`を部分的にスクリーンショットし、これを画像としてユーザー側に渡せるようにします。

- カードを`View`として表示
- この`View`をスクリーンショット
- 画像を保存または共有

# Viewの作り方

これは説明する必要がないと思うので省略しますが、後述する方法ならほとんどの`View`には対応できます。`Image`を使えば背景などに画像を組み込むこともでききます。自由度も高く、これを活かしてデザイナーさんに腕を振るってもらうのもいいかもしれません。

# スクリーンショット

## iOS16から

その名の通り画像レンダリングを行う`ImageRenderer`を使うことができます。

```swift
import SwiftUI

struct ContentView: View {
    
    // ここでは、共有する画像を管理するための変数shareImageを定義しています。
    @State private var shareImage: UIImage? = nil
    
    var body: some View {
        VStack {
            CardView()
            // "render"というタイトルのボタンを作成します。このボタンを押すと、renderメソッドが呼び出されます。
            Button("render") {
                render()
            }
        }
    }
    // renderメソッドは、shareViewを画像としてレンダリングします。
    // ここでは使いませんが.cgImageも選択可能です
    @MainActor
    func render()  {
        let renderer = ImageRenderer(content: shareView())
        shareImage = renderer.uiImage
    }
}

```

## UIViewRepresentableを使う

iOS16以前でもこれと似た動きを実装することができます。まずはUIViewRepresentableを作りましょう。

```swift
import SwiftUI

/// `ScreenshotMaker`を引数とするクロージャー
typealias ScreenshotMakerClosure = (ScreenshotMaker) -> Void

/// `ScreenshotMaker`ビューのSwiftUI表現
struct ScreenshotMakerView: UIViewRepresentable {

    let closure: ScreenshotMakerClosure

    /// クロージャーを使用して`ScreenshotMakerView`を初期化します
    init(_ closure: **@escaping** ScreenshotMakerClosure) {
      self.closure = closure
    }

    func makeUIView(context _: Context) -> ScreenshotMaker {
        let view = ScreenshotMaker(frame: CGRect.zero)
        return view
    }

    func updateUIView(_ uiView: ScreenshotMaker, context _: Context) {
        DispatchQueue.main.async {
          closure(uiView)
        }
     }
}

  

/// スクリーンショットを撮るためのカスタムUIViewサブクラス
class ScreenshotMaker: UIView {

    /// このビューの親の、さらに親ビューのスクリーンショットを撮ります
    /// - Returns: ビューのスクリーンショットを含むUIImage
    func screenshot() -> UIImage? {
       guard let containerView = superview?.superview,

        let containerSuperview = containerView.superview else { return nil }
        let renderer = UIGraphicsImageRenderer(bounds: containerView.frame)
        return renderer.image { context in
        containerSuperview.layer.render(in: context.cgContext)
        }
    }
}

extension View {
    /// ビューにスクリーンショットのオーバーレイを追加します
    /// - Parameter closure: スクリーンショットを撮る際に実行されるクロージャー
    /// - Returns: スクリーンショットのオーバーレイが追加された変更されたビュー
    func screenshotView(_ closure: @escaping ScreenshotMakerClosure) -> some View {

      let screenshotView = ScreenshotMakerView(closure)

      return overlay(screenshotView.allowsHitTesting(false))
    }
}
```

管理用の`ScreenshotMaker`クラスを使う必要はありますが、ほとんど同じような形で実装することができます。ちなみに画像のサイズや画質などについては両方の手法に大きな差は見られませんでした。

```swift
import SwiftUI

struct ContentView: View {

 @State var screenshotMaker: ScreenshotMaker?
 @State private var shareImage: UIImage? = nil

  var body: some View {

    VStack {
      CardView()
      Button("render") {
         render()
      }
    }.screenshotView { screenshotMaker in
       self.screenshotMaker = screenshotMaker
    }
  }

  func render() {
   shareImage = screenshotMaker?.screenshot()
  }
  
}

```

# 共有・保存

![ユーザーが設定を入力 1280x670 (1).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/713689/a57d3390-26f2-6409-2631-5e4b836d5088.png)


## iOS16から

`ShareLink`を使うことができます。ただし、これはURLをシェアする目的が主のようなので、画像を共有するにはTransferable型に準拠した独自の型を作る必要があります。共有時のシートに表示されるタイトルや説明などは

```swift
import SwiftUI

struct ScreenshotItem: Transferable {

  static var transferRepresentation: some TransferRepresentation {
    // ここが共有するのはimageであると伝える役割
    ProxyRepresentation(exporting: \.image)
  }

  public var image: Image
  public var caption: String
}

  

struct ContentView: View {

   @State private var photo = ScreenshotItem(image: 
     Image("appicon"),caption: "sample")

   var body: some View {
     VStack {
       CardView()
         .onAppear {
             render()
         }
       ShareLink(
          item: photo,
          //以下でシート上での表示や説明を変更
          subject: Text("Evekiカード"),
          message: Text("共有してみよう！"),
          preview: SharePreview(
                     photo.caption,
                     image: photo.image)
        )
      }
     }

  
   func render() {
       DispatchQueue.main.async {
        let renderer = ImageRenderer(content: shareView())
        if let uiImage = renderer.uiImage {
            photo.image = Image(uiImage: uiImage)
          }
       }
    }

}

```

## UIKitを使う

`UIActivityViewController`をSwiftUIに対応させる形をとります。ただシート上での表示を細やかに切り替えるには`LinkPresentation`フレームワークが必要です。

```swift
import SwiftUI
import LinkPresentation

class ShareActivityItemSource: NSObject, UIActivityItemSource {
    
    var shareText: String
    var shareImage: UIImage
    var linkMetaData = LPLinkMetadata()
    
    init(shareText: String, shareImage: UIImage) {
        self.shareText = shareText
        self.shareImage = shareImage
        linkMetaData.title = shareText
        super.init()
    }
    
    func activityViewControllerPlaceholderItem(_ activityViewController: UIActivityViewController) -> Any {
        return UIImage(named: "AppIcon ") as Any
    }
    
    func activityViewController(_ activityViewController: UIActivityViewController, itemForActivityType activityType: UIActivity.ActivityType?) -> Any? {
        return nil
    }
    
    func activityViewControllerLinkMetadata(_ activityViewController: UIActivityViewController) -> LPLinkMetadata? {
        return linkMetaData
    }
}

struct ShareSheet: UIViewControllerRepresentable {
    
    @Binding var image: UIImage
    let text = "Evekiカード"
    func makeUIViewController(context: Context) -> UIActivityViewController {

        
        let itemSource = ShareActivityItemSource(shareText: text, shareImage: image)
        let activityViewController = UIActivityViewController(activityItems: [itemSource], applicationActivities: nil)
   
        return activityViewController
    }
    func updateUIViewController(_ vc: UIActivityViewController, context: Context) {
    }
    
}

```
基本的には以下のよう呼び出すだけで使えます。ただiOS16の場合と異なり、画面いっぱいのモーダル画面が表示されます。機能としては問題ありませんが、もし気になる場合は[この記事](https://qiita.com/SNQ-2001/items/86646b661ccc4a7a9034)が修正の参考になります。

```swift

struct ContentView: View {
    //省略
    @State private var showSheet = false
    
    var body: some View {
       //省略
        Button("Share") {
            showSheet.toggle()
        }.sheet(isPresented: $showSheet) {
            ShareSheet(image: $shareImage)
        }
        
    }
 
}
```
# さいごに


スクリーンショットと共有機能を組み合わせれば、アプリ上で画像生成を行うことが可能です。最近は情報の記録をスクリーンショットで行う人も多いと聞きます。また、画像の投稿がきっかけで話題になるアプリやサービスも増えています。万が一アプリを削除されてしまっても、カメラロールに痕跡を残しておけるというメリットもあります。発想次第では分野を問わず活用できると思うので、ぜひ一度試してみてください。

# 参考


- [Take screenshots of SwiftUI Views - DEV Community](https://dev.to/gualtierofr/take-screenshots-of-swiftui-views-2h8n)