アクトインディ開発者ブログ

子供とお出かけ情報「いこーよ」を運営する、アクトインディ株式会社の開発者ブログです

使い勝手とパフォーマンスを意識した詳細画面の実装

iOS アプリエンジニアの namikata です。この度、いこーよアプリでは GW の大型連休に合わせて、スポット詳細画面のリニューアルを行いました。

いこーよアプリが誕生したのが約 3 年前。3 年間 iOS アプリ の開発を担当されてもらって、今回のリニューアルでは、リリース当時は技術的にも時間的にも対応する事ができなかった部分の工夫を行う事ができたので、いくつか紹介したいと思います。 UITableView で組んでいます。

f:id:t-namikata:20190507154821g:plain:w300

工夫した点

UI に関する事

  • 画像の上部全画面表示
  • 写真を下部までスライドしたら navigation を切り替える
  • スクロールに合わせて固定される Menu
  • Web を彷彿とさせるページ内リンク
  • TableView の Cell の中で、要素の繰り返しが発生した場合は StackView を利用する

※ UI に関しては、簡単なサンプルを github に公開しましたので、良ければそちらと合わせて確認してください。 https://github.com/takanamishi/DetailViewController

パフォーマンスに関する事

  • 一覧で取得した情報を、詳細のファーストビューで利用する
  • TableView の Cell の中でマップを表示する
  • ViewController を生成したタイミングで必要な API はコール。結果は Observe する。

UI に関する事

画像の上部全画面表示

f:id:t-namikata:20190507155051j:plain:w300

多くの情報を伝える必要のある詳細ページでは、情報を表示できるエリアの大きさは重要です。以前は、ナビゲーションバーを常に表示していたのですが、新しいデザインでは、 StatusBar に被せて写真を表示するように変更になりましたので、その実装方法について紹介したいと思います。色々と試し試しで実装してみて完成したのですが、大きなデメリットが生まれてしまう(scrollView の contentOffset.y のスタートがマイナスから始まる)方法で、もっと分かりやすい実装に今後変更したいと思います。

1. AutoLayout の設定

いつも通り tableView の autolayout を上下左右 safeArea に設定

f:id:t-namikata:20190507155302p:plain

iPhone の端末によって statusBar の高さが異なるので、コードで調整できるように top の制約を IBOutlet で紐付け

f:id:t-namikata:20190507155342p:plain

2. コードで調整

viewDidLoad で statusBar の高さ分、 negative に指定してあげます。ここでよく分からない現象なんですが、単純に scrollViewTopConstraint.constant = -statusBarHeight だと上部に余白が残ってしまい -statusBarHeight * 2 で上手く行きました。何故なのか。

private var statusBarHeight: CGFloat {
    return UIApplication.shared.statusBarFrame.size.height
}

override func viewDidLoad() {
    super.viewDidLoad()

    // 上には statusbar 分しか余白がないはずなのに * 2 しないとうまくいかない。
    scrollViewTopConstraint.constant = -statusBarHeight * 2
}

これで statusBar に被る形で表示を行う事ができました。

f:id:t-namikata:20190507155417p:plain:w300

この方法で個人的に一番辛いのが scrollVeiw の contentOffset が -44px から始まる事です(iPhone XR の場合。 iPhone8 とかだと異なる値です)。 scrollViewDidScroll などで処理を行う場合に、常に contentOffset がずれている事を考慮しないといけなくなってしまいました。微妙な対応ですが tableViewContentOffsetY プロパティを定義して運用しています。

private var tableViewContentOffsetY: CGFloat {
    return tableView.contentOffset.y + statusBarHeight
}

写真を下部までスライドしたら navigation を切り替える

f:id:t-namikata:20190507161325g:plain:w300

NavigationBar を追加します

f:id:t-namikata:20190507155525p:plain

下の画像が見えるように NavigationBar の背景色を clear にします。

@IBOutlet weak var navigationBar: UINavigationBar!
override func viewDidLoad() {
    super.viewDidLoad()

    scrollViewTopConstraint.constant = -statusBarHeight * 2
    navigationClear()
}

private func navigationClear() {
    navigationBar.setBackgroundImage(UIImage(), for: .default)
    navigationBar.shadowImage = UIImage()
    navigationBar.titleTextAttributes = [.foregroundColor: UIColor.clear]
    navigationBar.tintColor = UIColor.white
}

scrollViewDidScroll で menu を固定したい位置までスクロールしたら、 navigation を切り替えれば OK です。

private var isScrollOverImageViewCell: Bool {
    // 簡略化の為 UIScreen から高さを計算。デザインによって高さの算出は cell.frame.height するなり個別に対応
    let imageViewCellHeight = UIScreen.main.bounds.width * 3 / 4
    return tableViewContentOffsetY > imageViewCellHeight - (statusBarHeight + navigationBar.frame.height)
}

extension ViewController: UIScrollViewDelegate {
    func scrollViewDidScroll(_ scrollView: UIScrollView) {
        switchNavigation()
    }

    private func switchNavigation() {
        if isScrollOverImageViewCell {
            let boldFont = UIFont.boldSystemFont(ofSize: 16)
            navigationBar.titleTextAttributes = [ .font: boldFont, .foregroundColor: UIColor.white]
        } else {
            navigationBar.titleTextAttributes = [.foregroundColor: UIColor.clear]
        }
    }
}

スクロールに合わせて固定される Menu

f:id:t-namikata:20190507155440g:plain:w300

TableView で実装してあるので、一つ一つの要素が Cell になってます。 Cell の中の Menu をスクロールに合わせて固定する事は非常に難しそうなので、同じデザインの Menu を 2 つ用意し、固定したいポジションのところまできたら、Menu の hidden を切り替える実装にしました。

PageLinkView の CustomView を作り ViewController と ImageViewCell に追加します。

f:id:t-namikata:20190507155933p:plain

f:id:t-namikata:20190507155956p:plain

NavigationBar の時と同様に scrollViewDidScroll で切り替え処理を行います。画像の下部にスクロールポジションが来たら表示してあげればいいので fixedPageLinkView.isHidden = !isScrollOverImageViewCell を追加してあげるだけで終わりです。

func scrollViewDidScroll(_ scrollView: UIScrollView) {
    switchNavigation()
    // 以下を追加
    fixedPageLinkView.isHidden = !isScrollOverImageViewCell
}

Web を彷彿とさせるページ内リンク

f:id:t-namikata:20190507155542g:plain:w300

TableView で実装している場合 scrollToRow か setContentOffset が主に使われるスクロール方法になるかと思います。 scrollToRow が実装が簡単なので使いたい所ですが、今回のサンプルでは利用できません。何故なら tableView に重なる形で NavigationBar と PageLinkView が配置されているので、その分スクロールが完了した時に下にずらしてあげないといけないからです。

スクロールしたい箇所は大抵セクションで別れているので、セクションヘッダーがある場合は rectForHeader 、セクションヘッダーがない場合は rectForRow でスクロールポジションを取得する事ができます。

enum SectionType: Int {
    case image = 0
    case experiences
    case tickets
}

func jump(sectionType: SectionType) {
    // menu のカラー変更
    fixedPageLinkView.changeDisplay(type: sectionType)
    // statusBarHeight * 2 は全画面表示の影響で contentOffset.y がマイナスからスタートする為
    let adjustment = (statusBarHeight * 2) + navigationBar.frame.height + fixedPageLinkView.frame.height
    let indexPath = IndexPath(row: 0, section: sectionType.rawValue)
    let targetScrollPosition = tableView.rectForRow(at: indexPath).origin.y - adjustment
    tableView.setContentOffset(CGPoint(x: 0, y: targetScrollPosition), animated: true)
}

実装時の注意点としては、TableView の Header と Footer を動的な高さとなるように設定していた場合、適切に estimatedHeight を返してあげないと、正常な位置にスクロールしてくれない事があったので、正しい位置にスクロールできなかった場合は確認してみてください。

TableView の Cell の中で、要素の繰り返しが発生した場合は StackView を利用する

TableView で大枠のレイアウトを組んでいる時に、 Cell の中で、さらに TableView を呼びたくなるケースが出て来る事があります。今回のいこーよの場合は、コンビニで販売するチケット情報の部分でそれが発生しました。

f:id:t-namikata:20190507155717g:plain:w300

チケットの Box が 2 つあり、それぞれの Box の中に、金額が表示されたチケットが 2つ, 3つと配置されてます。

TableView の AutomaticDimension を有効にし、 Cell で指定した AutoLayout で Self-Sizing を行った場合に、Cell の中で利用するのに厄介になる View の一つに TableView があります。TableView は UILabel 等と違って、高さの制約を指定しないと、Cell の中では正しい高さで表示してくれません。

そこで活躍するのが StackView です。TableView の時と同じように、繰り返したい要素となるチケットの金額部分をカスタム View として作成します。

f:id:t-namikata:20190507160043p:plain

そして、Cell のセットアップで addArrangedSubview します。

f:id:t-namikata:20190507160105p:plain

class TicketsCell: UITableViewCell {
    @IBOutlet weak var ticketsStackView: UIStackView!

    func setup(tickets: [String]) {
        for view in ticketsStackView.arrangedSubviews {
            ticketsStackView.removeArrangedSubview(view)
        }

        for ticket in tickets {
            let view = TicketView()
            view.setup(name: ticket)
            ticketsStackView.addArrangedSubview(view)
        }
    }
}

これで、増減する要素の繰り返し処理を TableView を用いずに実装する事ができました。簡単なサンプルを github に公開しましたので、良ければそちらと合わせて確認してください。 https://github.com/takanamishi/DetailViewController

パフォーマンス

一覧で取得した情報を、詳細のファーストビューで利用する

f:id:t-namikata:20190507155756g:plain:w300

Master-Detail App のように、一覧があり、一覧の Cell をタップすると詳細画面が表示される作りの場合、詳細画面に遷移したタイミングで API を Call して、詳細情報を表示するパターンは多いと思います。その時に API のリクエスト中は、ファーストビューに一覧の情報を表示しておき、取得が完了したら reload して全ての情報を表示するようにする事で、読み込み中である事をそこまで意識させる事なくページ遷移を行う事ができます。一覧で表示する内容は、詳細を閲覧する為の判断材料となるキャッチーな情報で構成されているので、一覧で取得した情報を、詳細のファーストビューで表示する事は自然な形だと思います。

TableView を利用している場合は、状態の変化による View の切り替えが容易に行えます。

1. Loding を表す Cell を追加します

class DetailViewController: UIViewController {
    enum SectionType: Int {
        case firstView = 0
        case detail

        static func get(_ section: Int) -> SectionType {
            guard let type = SectionType(rawValue: section) else {
                fatalError("定義されていないセクションです")
            }

            return type
        }
    }

    @IBOutlet weak var tableView: UITableView! {
        didSet {
            // 一覧の情報を利用して表示する Cell
            tableView.register(nibWithCellClass: FirstViewCell.self)

            // 詳細情報の API のリクエストが完了しないと表示できない Cell
            tableView.register(nibWithCellClass: DetailCell.self)

            // API のリクエスト中に表示するローディング用の Cell
            tableView.register(nibWithCellClass: LodingCell.self)
        }
    }

2. numberOfSection では API のリクエストは FirstView 用のセクションのみ表示

func numberOfSections(in tableView: UITableView) -> Int {
    // state は API リクエストの状態で requesting , completed , error など
    switch state {
    case .completed:
        return 2 // 全てのセクションを表示
    case .requesting:
        return 1
    }
}

3. API のリクエスト中は LoadingCell の表示

func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
    switch state {
    case .completed:
        switch SectionType.get(section: section) {
        case .firstView:
            return 1
        case .detail:
            return 1
        }
    case .requesting:
        return 2 // FirstViewCell用に 1 、 LoadingCell 用に 1
    }
}

func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    switch SectionType.get(indexPath.section) {
    case .firstView:
        if indexPath.row == 0 {
            let cell = tableView.dequeueReusableCell(withClass: FirstViewCell.self, for: indexPath)
            return cell
        } else {
            let cell = tableView.dequeueReusableCell(withClass: LodingCell.self, for: indexPath)
            return cell
        }
    case .detail:
        let cell = tableView.dequeueReusableCell(withClass: DetailCell.self, for: indexPath)
        return cell
    }
}

ちょっとトリッキーな方法ですが、こうする事で、詳細情報の API のリクエストが完了したタイミングで reloadData すると、全てのコンテンツが表示されるようにする事ができます。

ViewController を生成したタイミングで必要な API はコール。結果は Observe する。

詳細画面は、一覧から何度も表示が行われる為、画面を表示する速度は使い勝手の部分に大きく影響します。以前までは以下のように ViewDidLoad の中で API を Call していましたが、 ViewDidLoad は、View の生成が終わり、View にアクセスできる状態になってから呼び出されるライフサイクルの為、 View の準備云々とは関係のない API の Call はもっと早いタイミングで行う方が良いです。

@IBOutlet weak var textLabel: UILabel!

override func viewDidLoad() {
    super.viewDidLoad()

    Alamofire.request("https://httpbin.org/get?id=1").responseJSON { response in
        textLabel.text = "facilityName"
    }
}

いこーよアプリでは、スポット一覧から、スポット詳細へ遷移する際に、以下のような形でスポット詳細のリクエストに必要なパラメーターを画面遷移時に受け渡すように実装しています。

一覧の ViewController

let vc = DetailViewController.instantiate() // DetailViewController の生成
vc.parameter(facilityId: 1) // スポット ID のセット
push(vc)

詳細の ViewController(DetailViewController)

func parameter(facilityId: Int) {
    self.facilityId = facilityId
}

この facilityId を受け取ったタイミングで API を Call してあげればいいですが、ここで気をつけないといけないのは viewDidLoad が呼び出される以前に textLabel などの view にアクセスすると、まだ view が生成されてなく NullPointerException でクラッシュしてしまう事です。

@IBOutlet weak var textLabel: UILabel!

func parameter(facilityId: Int) {
    self.facilityId = facilityId
    Alamofire.request("https://httpbin.org/get?id=\(facilityId)").responseJSON { response in
        textLabel.text = "facilityName" // textLabel はまだ生成されていない
    }
}

なので ViewDidLoad で API の結果を Observe してあげます(KVO や Rx の BehaivorRelay とか)。ViewDidLoad で Observe する事で viewDidLoad の呼び出しを待たずに API をコールする事ができ、 textLabel に値がセットできるタイミングで、値監視により、適切に値がセットされるようになります。

@IBOutlet weak var textLabel: UILabel!
private let facilityName = BehaviorRelay<String>(value: "")
private let disposeBag = DisposeBag()

override func viewDidLoad() {
    super.viewDidLoad()

    facilityName.subscribe(onNext: { [weak self] name in
        textLabel.text = name
    }
}).disposed(by: disposeBag)

func parameter(facilityId: Int) {
    self.facilityId = facilityId
    Alamofire.request("https://httpbin.org/get?id=\(facilityId)").responseJSON { [weak self] response in
        let facilityName = response から取得
        self?.facilityName.accept(faciltiyName)
    }
}

やった事は、API をコールして結果を取得するといった、プレゼンテーション(UI)に関する事とは関係のない処理に、 textLabel.text = facilityName のような View にアクセスする処理を書かずに切り離す事で、API をコールすべきタイミングできちんとコールできるように、View に値を反映できるタイミングで値を反映できるようにした事です。設計原則にプレゼンテーションとドメインの役割を整理して、処理をきちんと分けなさいといったプレゼンテーションとドメインの関心の分離( Presentation Domain Separation )がありますが、最近ようやく、実体験を通して、あぁなるほど、と思えるようになって来ました。

TableView の Cell の中でマップを表示する

子どもと一緒に遊びに行くお出かけ先を決める際に、距離や場所は判断材料として非常に重要な情報になる為、該当のスポットがどこにあるのか、視覚的に分かりやすいようにと、地図を表示する事になりました。

デザインはこのような形です。

f:id:t-namikata:20190507160141p:plain:w300

レイアウトは TableView で組んでいます。マップの表示としては、 iOS 標準の MapKit と Google が提供している Google Maps SDK がありますが、TableView の Cell 内で利用する場合は、 iOS 標準の MapKit 一択です。理由は MapKit には Snapshot といった Map の静止画を取得できる機能があり、生成と破棄を繰り返す TableViewCell の特性上、メモリ使用量の多いマップを直接 Cell の中には埋め込めないからです。 MapKit の MKMapView も Google Maps SDK も、Map の View を生成する際には 30 〜 40 MB ぐらいのメモリを使用するので、 TableViewCell での利用には適しません。MapKit の Snapshot を利用すれば、数 MB にメモリ使用量を抑える事ができます。

マップのキャプチャも 1 回行えば済むので、 UIImage を受け取って、なければキャプチャを取得する UIView クラスを作成しました。

import UIKit
import MapKit

protocol StaticMapCellDelegate: class {
    func mapCaptureCompleted(captureImage: UIImage)
}

class StaticMapCell: UITableViewCell {
    @IBOutlet weak var mapImageView: UIImageView!
    private weak var delegate: StaticMapCellDelegate?

    func setup(mapCaptureImage: UIImage?, delegate: StaticMapCellDelegate?) {
        self.delegate = delegate

        if let mapImage = mapCaptureImage {
            mapImageView.image = mapImage
        } else {
            captureMapView()
        }
    }

    func captureMapView() {
        let mapSnapshotOptions = MKMapSnapshotter.Options()
        let region = MKCoordinateRegion(center: facility.location, latitudinalMeters: 1000, longitudinalMeters: 1000)
        mapSnapshotOptions.region = region
        mapSnapshotOptions.scale = UIScreen.main.scale
        mapSnapshotOptions.size = CGSize(width: UIScreen.main.bounds.width, height: 180)
        mapSnapshotOptions.showsBuildings = true
        mapSnapshotOptions.showsPointsOfInterest = true
        let snapShotter = MKMapSnapshotter(options: mapSnapshotOptions)
        snapShotter.start() { [weak self] snapshot, error in
            if let image = snapshot?.image {
                self?.mapImageView.image = image
                self?.delegate?.mapCaptureCompleted(captureImage: image)
            }
        }
    }
}

キャプチャした画像は TableView のある ViewController で保持して使いまわしてもらいます

class ViewController: UIViewController, UITableViewDataSource {
    private var mapCaptureImage: UIImage?

    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withClass: StaticMapCell.self, for: indexPath)
        cell.setup(mapCaptureImage: mapCaptureImage, delegate: delegate)
        return cell
    }
}

extension ViewController: StaticMapCellDelegate {
    func mapCaptureCompleted(captureImage: UIImage) {
        mapCaptureImage = captureImage
    }
}

以上になります。

最後に

余談ですが、コンビニで買える施設のチケットって PayPay の 20% キャッシュバック対象に入るんですね :eye: ゴイスー。コンビニだったら LINE カードでも支払えるし、割引していない施設でも、コンビニチケットあったら、事前にコンビニで買ってから遊びに行った方がお得ですね。お子さんのいるパパエンジニア、ママエンジニアの方は、是非コンビニチケットご利用ください。PayPay!!