iOS 13から追加されたフルページのスクリーンショットの機能と対応方法の紹介

f:id:vasilyjp:20200331150230j:plain

こんにちは! ZOZOTOWN部の遠藤です。
iOS 13がリリースされて半年が経ちましたね。iOS 13といえばダークモード機能が注目を浴びましたが、それ以外にもたくさんの新しい機能が追加されました。
本記事では新しく追加されたフルページのスクリーンショットについて書いていきます。

はじめに

ZOZOテクノロジーズではiOS技術のキャッチアップのために定期的に社内勉強会を行っています。その勉強会で話題に上がったフルページのスクリーンショットに興味を持ち、ZOZOTOWNではどのように使用できるかの調査をしてみました。
今回は調査して分かった内容をもとにフルページのスクリーンショットの機能と対応方法について紹介します。
少しでもフルページのスクリーンショット機能を実装する際のお役に立てれば幸いです。

フルページのスクリーンショットとは?

フルページのスクリーンショットはiOS 13で追加されました。
フルページのスクリーンショット機能については、iOS 13で利用できる新機能の「システム体験」->「フルページマークアップ」に記載されています。

ウェブページ、iWorkの書類、Eメール、地図の全体をとらえたスクリーンショットを撮り、注釈を加えられます。

今まで、スクリーンショットは画面に写っている箇所しか撮れませんでしたが、iOS 13から画面外の要素もスクリーンショットで撮ることができるようになりました。

iOS 13のSafariでスクリーンショットを撮ると、このようにスクリーンショットのプレビューで「フルページ」というタブにページ全体のスクリーンショットが表示されます。
かつ、通常のスクリーンショットと同じくトリミングや文字を書くこともできます。

フルページのスクリーンショットに
対応するには?

フルページのスクリーンショットについてはWWDC19のIntroducing PencilKitで最後の5分ほど触れられています。

ユーザーがスクリーンショットを撮ると、iOS 13で追加されたUIScreenshotServiceのdelegateが呼ばれます。
呼ばれたdelegateメソッドの返り値にPDFデータを渡すことで、フルページのスクリーンショットが表示されます。

f:id:vasilyjp:20200405182952p:plain

スクリーンショットがPDFであることのメリット

PDFは複数ページ持つことが可能です。iOS 13のフルページのスクリーンショットは単一ページのスクリーンショットも複数ページのスクリーンショットも対応しています。

単一ページ例: Safari 複数ページ例: Keynote

フルページのスクリーンショット機能で提供するスクリーンショット

Introducing PencilKitのセッションでフルページのスクリーンショットを採用している事例としてマップアプリが紹介されています。
マップアプリの通常のスクリーンショットではセミモーダルが表示されていますが、フルページのスクリーンショットでは非表示になっており、マップの情報をより多く見ることができます。

通常のスクリーンショット フルページのスクリーンショット

Human Interface Guidelinesにはスクリーンショットは画面に写っている内容を変えてはいけないと書かれていますが、フルページのスクリーンショットは必ずしもそうとは限らないようです。

どのようなスクリーンショットがフルページのスクリーンショットとして表示するのが推奨されているかは、Human Interface GuidelinesやUIScreenshotServiceのドキュメントなどに記載されていません。(2020/04/01時点)

フルページのスクリーンショットに対応する

この画面にフルページのスクリーンショットを対応します。

ここでのフルページのスクリーンショットの定義は、コンテンツが収まるほど長い仮想の端末で見たときの状態のスクリーンショットです。

フルページのスクリーンショットを撮る手法はたくさんあるかと思いますが、今回は2つの手法を試してみました。

手法1: windowの高さを変更して描画する

windowの高さをコンテンツが収まるように更新してそのwindowを描画することで求めているスクリーンショットを撮ることができます。

f:id:vasilyjp:20200405183351p:plain

import UIKit

class ViewController: UIViewController, UIScreenshotServiceDelegate {

    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)

        view.window?.windowScene?.screenshotService?.delegate = self
    }

    override func viewWillDisappear(_ animated: Bool) {
        view.window?.windowScene?.screenshotService?.delegate = nil
    }

    func screenshotService(_ screenshotService: UIScreenshotService, generatePDFRepresentationWithCompletion completionHandler: @escaping (Data?, Int, CGRect) -> Void) {
        let contentHeight: CGFloat // コンテンツが収まる高さを計算する
        let renderer = UIGraphicsPDFRenderer(bounds: .init(origin: .zero, size: .init(width: view.frame.width, height: contentHeight)))
        let data = renderer.pdfData { context in
            context.beginPage()
            let originalHeight = view.frame.height
            view.window?.frame.size.height = contentHeight
            view.window?.layer.render(in: context.cgContext)
            view.window?.frame.size.height = originalHeight
        }
        UIGraphicsEndPDFContext()
        completionHandler(data as Data, 0, .zero)
    }
}

順番に説明します。

(1) screenshotServiceのdelegate設定
screenshotServiceのdelegateにselfを設定します。

override func viewDidAppear(_ animated: Bool) {
    super.viewDidAppear(animated)

    view.window?.windowScene?.screenshotService?.delegate = self
}

(2) PDF作成の準備
UIGraphicsPDFRendererを使用してPDFを作成します。
UIGraphicsPDFRendererの初期化でPDFを描画する領域のサイズを決めます。
Safariでは幅375ポイントのデバイスでフルページのスクリーンショットを撮ると、幅375ポイントのPDFが生成されました。今回は同じようにデバイス幅をそのまま使います。

func screenshotService(_ screenshotService: UIScreenshotService, generatePDFRepresentationWithCompletion completionHandler: @escaping (Data?, Int, CGRect) -> Void) {
    let contentHeight: CGFloat // コンテンツが収まる高さを計算する
    let renderer = UIGraphicsPDFRenderer(bounds: .init(origin: .zero, size: .init(width: view.frame.width, height: contentHeight)))
    let data = renderer.pdfData { context in
        context.beginPage()
        // viewの描画
    }
}

(3) PDF作成
pdfData(actions:)のメソッドを使用し、actions内で描画するとPDFデータが取得できます。
描画をする際の注意点ですが、NavigationBarとTabBarも描画したい場合はviewのwindowを描画します。viewを描画してもviewの階層構造にないNavigationBarとTabBarは描画されないからです。
今回はNavigationBarとTabBarも描画したいので、viewではなくwindowを描画することにしました。

また、描画メソッドはCALayerのrender(in:)を使用します。描画メソッドにはdrawHierarchyもありますが、windowの高さを高くしすぎると描画されないことがあるためです。

let data = renderer.pdfData { context in
    context.beginPage()
    let originalHeight = view.frame.height
    view.window?.frame.size.height = contentHeight
    view.window?.layer.render(in: context.cgContext)
    view.window?.frame.size.height = originalHeight
}
UIGraphicsEndPDFContext()

(4) completionHandlerにPDFを渡す
最後に作成したPDFを渡して完了です。
completionHandlerはPDFの他に2つのパラメータがあります。この2つのパラメータは、スクリーンショットのプレビューで表示位置を指定するものです。
単一ページの場合はrectInCurrentPageを指定でき、複数ページの場合はindexOfCurrentPageのパラメータが指定できます。
rectInCurrentPageにCGRectZero、indexOfCurrentPagに0を指定するとPDFの一番上から表示されますが、ユーザーが表示していた位置を指定するのが良いでしょう。

completionHandler(data as Data, 0, .zero)

これでフルページのスクリーンショットを撮ることができます。

f:id:vasilyjp:20200405184056g:plain



目的のスクリーンショットを得ることはできましたが、windowの高さを変更することは、レイアウト崩れや一度に全てのcellがロードされることで発生する負荷などの副作用が不安になります。

手法2: 1画面ずつスクロールして描画する

windowの高さを変えずに、コンテンツを1画面ずつスクロールしながら描画します。

f:id:vasilyjp:20200405184150p:plain

スクロールのたびにwindowを描画すると、NavigationBarとTabBarが繰り返し描画されてしまいます。
これを回避するために、スクロール時はスクロールのコンテンツのみを描画します。

先程紹介した(3)の描画処理を変更します。

ファーストビューはwindowを描画し、その後1画面ずつスクロールしてscrollViewを描画します。ここでの注意点はTabBarの描画です。
windowを描画することで、TabBarも描画され、スクロールコンテンツの途中にTabBarが表示されてしまいます。
なので、windowを描画する前にTabBarを非表示にし、スクロールコンテンツが全て描画されたあとにTabBarのみを描画します。

let data = renderer.pdfData { context in
    context.beginPage()
    let originalContentOffset = scrollView.contentOffset
    // 全てのコンテンツが表示されるのに必要なスクロール回数を計算する
    let numberOfScrolls = ceil(contentHeight / scrollView.frame.size.height)
    (0..<Int(numberOfScrolls)).forEach {
          if $0 == 0 {
              scrollView.contentOffset.y = 0
              tabBar.isHidden = true
              view.window?.drawHierarchy(in: view.frame, afterScreenUpdates: true)
          } else {
              let y = scrollView.frame.size.height * CGFloat($0)
              scrollView.contentOffset.y = y
              scrollView.drawHierarchy(in: .init(origin: .init(x: 0, y: y), size: scrollView.frame.size), afterScreenUpdates: true)
          }
    }

    tabBar.isHidden = false
    // コンテンツの下に描画されるようにy座標を計算する
    var drawTabBarFrame = tabBar.frame
    drawTabBarFrame.origin.y = contentHeight - tabBar.frame.height
    tabBar.drawHierarchy(in: drawTabBarFrame, afterScreenUpdates: true)

    scrollView.contentOffset = originalContentOffset
}
UIGraphicsEndPDFContext()

これでwindowの高さを変更せずともフルページのスクリーンショットを撮ることができました。
しかし、この手法はwindowを伸ばす手法と異なるレイアウトの箇所が出てきます。

コンテンツが収まる高さで描画した
スクリーンショット
1画面ずつスクロールして描画した
スクリーンショット

本来右下のFloating Action Buttonの位置はTabBarのすぐ上にあるのですが、1画面ずつスクロールして描画したスクリーンショットではファーストビューと同じ位置で表示されています。ファーストビューでwindowを描画した際にTabBarは非表示にしましたが、Floating Action Buttonは非表示にしていないためです。
スクロールコンテンツの最後に表示する要素についてはTabBar同様にスクロールの最後に描画するなどの工夫が各画面で必要になると思われます。

2つの手法のメリット・デメリット

2つのフルページのスクリーンショットの手法を比較してみました。

メリット デメリット
windowの高さを変更する手法 実装が簡単 windowの高さを変更することでの副作用
1画面ずつスクロールする手法 windowの高さを変更する手法と比べ副作用は低い 実装量が多い

実装量が多くなってしまいますが、1画面ずつ描画する手法の方が安全性は高いです。windowの高さを変える手法もリスクが許容できる画面であれば有効だと思います。

まとめ

今回はフルページのスクリーンショット機能と対応方法についての紹介でした。

フルページのスクリーンショットは、UIScreenshotServiceDelegateを設定しPDFを渡すことで対応できます。今回は長い端末で見たときの状態を目指しましたが、提供したい内容によっては複数ページや重要なところだけを抜き出したスクリーンショットを作ることも可能です。
この記事がフルページのスクリーンショットを実装の一助になれば幸いです。

ZOZOテクノロジーズでは、iOSエンジニアを募集しています。興味のある方はこちらからご応募ください!

www.wantedly.com

カテゴリー