Sansan Tech Blog

Sansanのものづくりを支えるメンバーの技術やデザイン、プロダクトマネジメントの情報を発信

Sansan iOS アプリの iPad 対応

こんにちは。iOSチームの髙橋です。皆さんは年毎にテーマや目標があったりしますか?

年が明けてから3週間ほど経ち、まだ今年のテーマで悩んでいましたが、肌荒れや体力の低下など心身の不調を強く感じることが多くあったので今年のテーマは健康になりました。今年は基礎となる主に体力づくりなどに励んでいくつもりです。

さて、今回は昨年 Sansan の iOS アプリで行った iPad 対応についてのお話をしたいと思います。

まだ、最低限の対応しかしてはいませんが、それでも存外対応することが多かったのでこれから iPad にアプリ対応させたいけどどこから始めたら良いのやら?という人の手助けになればなと思います。

下調べ

iPad 対応を行うにあたって、僕も初めてでしたし、チームとしても対応を行ったことがなかったのでまずは何をする必要があって、どこまでやるべきかというところを明確にする必要がありました。

どこまでやるかと言っても、当時の Sansan iOS アプリは全く iPad 対応をしておらず、画面いっぱいにアプリを表示するところから始めて UISplitView への対応や iPad 用アプリとしてのレイアウトの最適化などやるべきことは無限にありそうで、きちんとラインを引かないと終わらなさそうでした。 そのため、まずは iPad で使えるようにするために最低限どこまでやるべきか?という観点で整理し始めました。

調べていくと 2020年11月 の段階では大きく分けると以下のことをやる必要がありそうでした。

  • iPad アプリ向けのアイコンと App Store の iPad 用のスクショの準備
  • UIActivityViewController, UIAlertController (preferredStyle が actionSheet のみ) を利用している箇所について Popover の設定を行う
  • 縦向き、横向きどちらでも使えるように画面回転への対応
  • SplitView の機能が使えるように画面分割への対応

Popover の対応はサボると iPad で UIActivityViewControllerUIAlertController(actionSheet) を表示しようとした際にアプリがクラッシュするので対応必須です。また、画面回転と SplitView への対応はサボってもクラッシュこそしないものの、対応をしておかないと App Store の審査でリジェクトされてしまう のでこちらの二つも対応必須です。

iPad 対応にアサインされてからどんなことをすべきか軽く眺めていて、ぼんやりととりあえず最低限 Popover の対応だけ行えば良いのかなと思っていましたが想像していたよりもやることが多く、きちんと下調べをしておいてよかったです。

対応したこと

大きく分けると上記4つの内容を対応していきましたが、対応したことが細かく色々あるので(特に下二つ) 順番に紹介していければと思います。

Popover の対応について

iOS では UIActivityViewControllerUIAlertController(actionSheet) を利用すると下からニョキッと出てくる形になりますが、iPad では画面の大きさを活かすため、UIPopoverPresentationController というツールチップのような見た目に変わります。

Popover への対応はやるべきことは非常にシンプルで、UIActivityViewControllerUIAlertController を利用している箇所を洗い出して、一つ一つ次のように popover を表示するのに必要な sourceView, sourceRect を設定していきます。

let activityViewController = UIActivityViewController(activityItems: activityItems, applicationActivities: [])
// sourceView は Popover を表示したい View の親View を設定
activityViewController.popoverPresentationController?.sourceView = stackView
// sourceRect は Popover を表示したい View の frame を設定する
activityViewController.popoverPresentationController?.sourceRect = sourceView?.frame ?? .zero

例えば以下の左側のスクショのように Popover を表示したい場合は、右側のスクショの枠部分をそれぞれ sourceViewsourceRect として設定してあげるイメージです。

f:id:chaaaaanu:20210120094735p:plainf:id:chaaaaanu:20210120093901p:plain
左: 実際の表示, 右: View の設定イメージ

また、この二つに加えて Sansan では ActionSheetPicker-3.0 というライブラリを使っていたのでこちらの対応も必要でした。

github.com

ActionSheetPicker の場合はもうちょっとさっぱりしていて ActionSheet の初期化、表示の際に origin の引数に Popover を表示させたい View を設定してあげるだけです。

ActionSheetDatePicker(
            title: "タイトル",
            datePickerMode: .date,
            selectedDate: Date(),
            doneBlock: { picker, value, origin in /* 適当な処理 */ }
            cancel: nil,
            origin: sourceView // <- ここに View をセットする
)

これらの Popover への対応についてやること自体はシンプルでしたが、対応箇所が多かったり、表示する viewframeUIActivityViewController の表示箇所まで持ってくるのが大変なところがいくつかあり、結構時間をとってしまいました。

今後の開発でも対応が必要になることを踏まえ、共通化も考えましたが、結局 View を持ってくるのが一番大変な所であり、画面構成に強く影響を受けるので共通化は断念しました。

画面回転の対応について

こちらは iPad アプリに対して画面回転を許可するかどうかを設定する以外は、主にレイアウト崩れを修正していく対応でした。

画面回転を許可する

画面回転を許可するかどうかは Target -> General -> Deployment Info にある Device Orientation の部分にチェックを入れていくのが一番手っ取り早いです。

f:id:chaaaaanu:20210120004124p:plain
これだと iPhone でも画面回転できるようになってしまうため断念

しかし、Sansan の場合は iPad アプリのみ画面回転を許可したかったため、以下のように Info.plist で iPhone, iPad に個別で画面回転の設定を追加しました。

<key>UISupportedInterfaceOrientations~ipad</key>
<array>
    <string>UIInterfaceOrientationPortrait</string>
    <string>UIInterfaceOrientationPortraitUpsideDown</string>
    <string>UIInterfaceOrientationLandscapeLeft</string>
    <string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~iphone</key>
<array>
    <string>UIInterfaceOrientationPortrait</string>
</array>

Sansan の iPhone アプリはデバイスを回転させての利用は想定しておらず、iPhoneX 等のノッチのある端末で NavigationBar などを中心にレイアウト崩れが発生していました。仮に iPhone まで画面回転の対応をしないといけないとなると、対応に必要な工数があらかじめプロジェクトに割り振られていた工数を大幅に超えてしまい、プロジェクトとして進めるのがかなり厳しい状態になっていたため、個別に設定できるようになっていてよかったです。 また、冒頭で紹介したリジェクトは iPad アプリが画面回転に対応していない場合に発生するようなので iPhone アプリは画面回転に対応していなくても大丈夫そうです。

レイアウト崩れの修正

レイアウト崩れに関しては幸いにも画面の広い範囲で AutoLayout を利用していたため、それほど大きな修正は必要なかったです。修正する場合は必要に応じて viewWill/DidLayoutSubviewslayoutSubviews にコンポーネントのレイアウト処理を移していくだけでした。

基本的なところではありますが、やむをえない事情で手動でレイアウトを組む場合はコンポーネントを View に追加する処理と、追加した View をレイアウトする処理はしっかり分けられているとより対応がしやすいと思います。

カメラのプレビューと撮影後の写真の向きの修正

画面回転で見落としそうだったのはカメラの修正でした。

Sansan では名刺をカメラで撮影して、名刺データを取り込むことができますが、シャッター音が気になるということで動画の一部を切り取って写真を撮影しています。

名刺撮影時に表示しているプレビューと撮影後の写真とそれぞれが iPad を回転させると意図した向き、箇所が撮影されておらず、おかしなことになっていました。

プレビューに関しては AVCaptureVideoPreviewLayer を利用しており、AVCaptureVideoPreviewLayer.connection.videoOrientation を、撮影後の写真に関しては AVCaptureConnection.videoOrientation をそれぞれ画面の回転に合わせて更新してあげれば OK でした。

画面回転時の向きについては UIApplication.shared.statusBarOrientation を取得し、それを変換用のクラスを噛ませて AVCaptureVideoOrientation に変換して対応しました。

対応の雰囲気を把握していただくために簡易版のコードも載せておきます。

protocol VideoOrientationTranslatorInterface {
    func translate(from orientation: UIInterfaceOrientation) -> AVCaptureVideoOrientation?
}

struct VideoOrientationTranslator {
    public init() {}

    // UIApplication.shared.statusBarOrientation の値をキーとして AVCaptureVideoOrientation の要素をマッピング
    private let orientations: [UIInterfaceOrientation: AVCaptureVideoOrientation] = [
        .landscapeLeft: .landscapeLeft,
        .landscapeRight: .landscapeRight,
        .portrait: .portrait,
        .portraitUpsideDown: .portraitUpsideDown
    ]
}

extension VideoOrientationTranslator: VideoOrientationTranslatorInterface {
    func translate(from orientation: UIInterfaceOrientation) -> AVCaptureVideoOrientation? {
        guard orientation != .unknown else { return nil }

        return orientations[orientation]
    }
}
override func viewWillLayoutSubviews() {
    super.viewWillLayoutSubviews()
    
    // 諸々の更新処理・・・

    // 画面の向きが変わった時に、プレビューと撮影時の画像の向きも更新する
    updateVideoOrientation(from: UIApplication.shared.statusBarOrientation)
}

private func updateVideoOrientation(from orientation: UIInterfaceOrientation) {
    let translator = VideoOrientationTranslator()
    let videoOrientation = translator.translate(from: orientation)
    
    videoPreviewLayer.connect?.videoOrientation = videoOrientation
    videoConnection?.videoOrientation = videoOrientation
}

画面分割の対応について

画面分割は iPadOS 13 から追加された機能で、正式名称としては SplitView という名前で、アプリを二つ並べて利用することができる機能ですね。マルチタスキングとも呼ばれたりします。

こちらの機能を利用するために特に設定することはないです。 iPad 向けにアプリを公開するようにすれば自動的に使えるようになります。しゅごい。

f:id:chaaaaanu:20210120095226p:plainf:id:chaaaaanu:20210120095231p:plain
iPad で二つアプリを並べて利用できる SplitView

自動的に有効になる機能ではありますが、当然画面幅が変わるためレイアウトの修正などは必要になってくるのと、カメラ周りできちんと対応が必要だったので、その辺の話をできればなと思います。

カメラの停止の対応

iPad 対応していて一番驚いたのは SplitView 利用時に AVCaptureVideoPreviewLayer が止まってしまうということでした。当初は動かせる方法がないか探していましたが、調べても何も出てこず、他のアプリでも SplitView を解除してくださいと言った文言が表示されてカメラが止まっていたのでそちらに従うことにしました。 ちなみに ImagePickerController 経由でシステムのカメラを起動した時にはこちらを考慮する必要はありません。(普通にカメラが動きます。)

f:id:chaaaaanu:20210120004412g:plain
こんな風にカメラが止まるようにしました

Preview が 止められた/動き始めた というのは AVCaptureSessionWasInterrupted/AVCaptureSessionInterruptionEnded で通知を受け取れるので、そこから必要な処理を書いていきます。

func addNotification() {
    NotificationCenter.default.addObserver(forName: .AVCaptureSessionWasInterrupted, 
                                           object: nil, 
                                           queue: .main) { notification in
            // AVCaptureSessionInterruptionReasonKey から値を取得し、プレビューが止まった理由をチェックする
            let reason = (notification.userInfo?[AVCaptureSessionInterruptionReasonKey] as? Int)
                .flatMap { AVCaptureSession.InterruptionReason(rawValue: $0) }
            if let reason = reason, reason == .videoDeviceNotAvailableWithMultipleForegroundApps {
                // プレビューを止めたり、ラベルを表示したりする
            }
    }

    NotificationCenter.default.addObserver(forName: .AVCaptureSessionInterruptionEnded, object: nil, queue: .main) { _ in
        // プレビューを動かしたり、ラベルを非表示にしたりする
    }
}

まとめ

Sansan ではこれが iPad でアプリを動かす上での最低限の対応となりました。

あらかじめ下調べをしていたのですが、それでも自分が考えているよりは多くのことが考慮漏れとして上がってきてしまいました。 カメラ周りの問題は実装し始めてからの PR の中でメンバーに指摘をもらったり、結合テストの段階になってから発覚したりと、下調べだけでカバーしきることができず、もうちょっとしっかりアプリを動かしてチェックすべきだったなという反省点はありました。

カメラを使っていたりいなかったりアプリによってどこまで対応すれば最低限の対応になるのかというところは微妙に違ってくるのかなと思っていますが、大抵のアプリに共通する部分も含まれているかなと思うので、iPad 対応の一助になれば幸いです。

Sansan ではまだ iPad ユーザはそれほど多くはなく、一旦最低限の対応に止まりましたが、社有スマホが iPhone ではなく一律 iPad が支給されると言った会社さんがあることもちらほら聞いており、今後そう言った企業さんへの導入が増えていけばもう少し iPad アプリとしての利便性に向き合う時が来るのかなと思いました。自分自身 iPad のヘビーユーザーであり、 iPad 対応はすごく楽しくやりがいのあるプロジェクトだったので今後需要が伸びていけば iPad 用のレイアウトにアプリをガッツリ作り替える対応もしていければなと思っています。


buildersbox.corp-sansan.com buildersbox.corp-sansan.com

© Sansan, Inc.