モノトーンの伝説日記

Apex Legendsが大好き。

「PinLayoutのすゝめ」 〜 AutoLayout は重たいので繰り返し要素から PinLayout に変更しよう!

 こんにちは,モノトーンです。

 やはり自作アプリはとことんまでチューニングするので,スクロールが気持ち良いですね。

github.com

1. PinLayout を勧める理由。

 Texture ではなく,なぜ PinLayout を勧めるのか。

 それは単純に UIKit の既存資産を存分に活かせるから。

github.com

 やはり,UIKit の既存コード全てが活かせるのがでかいと思います。もちろん xib でのレイアウトは AutoLayout 制約だけ全て外してしまえばいいです(Install しなければ良い)

f:id:mntone:20190107155534p:plain

 私が測定した訳ではないですが,以下がサンプルコードらしいです。これの繰り返し要素 100 個という現実的な回答(実際には 1 画面 25 要素ぐらいが on memory になると思います)。

override func layoutSubviews() {
    super.layoutSubviews()
    
    let hMargin: CGFloat = 8
    let vMargin: CGFloat = 4
    
    optionsLabel.pin.topRight().margin(hMargin)
    actionLabel.pin.topLeft().margin(hMargin)
    
    posterImageView.pin.below(of: actionLabel, aligned: .left).marginTop(10)
    
    posterHeadlineLabel.pin.after(of: posterImageView, aligned: .center).marginLeft(4)
    posterNameLabel.pin.above(of: posterHeadlineLabel, aligned: .left).marginBottom(vMargin)
    posterTimeLabel.pin.below(of: posterHeadlineLabel, aligned: .left).marginTop(vMargin)
    
    posterCommentLabel.pin.below(of: posterTimeLabel).left(hMargin).marginTop(vMargin)
    
    contentImageView.pin.below(of: posterCommentLabel, aligned: .left).right().marginTop(vMargin).marginRight(hMargin)
    contentTitleLabel.pin.below(of: contentImageView).left().marginHorizontal(hMargin)
    contentDomainLabel.pin.below(of: contentTitleLabel, aligned: .left)
    
    likeLabel.pin.below(of: contentDomainLabel, aligned: .left).marginTop(vMargin)
    commentLabel.pin.top(to: likeLabel.edge.top).hCenter()
    shareLabel.pin.top(to: likeLabel.edge.top).right().marginRight(hMargin)
    
    actorImageView.pin.below(of: likeLabel, aligned: .left).marginTop(vMargin)
    actorCommentLabel.pin.after(of: actorImageView, aligned: .center).marginLeft(4)
}

 高速にスクロールすると,おおよそ 4 画面分の速度になりますね。4 画面描画するときの速度が 8〜12倍 (!!) って結構やばくないですか?

PinLayout/Benchmark.md at master · layoutBox/PinLayout · GitHub

2. 現行プロジェクトの話題を絡めつつ PinLayout について

 基本的に AutoLayout と頭で考えることはあまり変わりません。

 AutoLayout はなんらかの基準からなんらかの要素を固定していくように,PinLayout ではそのプロセスをコードとして起こすだけです。特に複雑なレイアウトで AutoLayout 貼る場合とそうでない場合,工数的にあまり変わらないと思います。

 人間的な観点で言えば,考え方のプロセスは同じだが,AutoLayout は連立方程式によって計算され,一方 PinLayout はあくまでコード実行としてシーケンシャルに計算されていくので,どちらかというと PinLayout の方が人間のプロセスに近いと思います。

 414pt 系端末と 320pt 375pt 系端末で layoutMargin がそれぞれ 20pt と 15pt でこれももちろん正しく扱えるのが個人的な採用理由でもあります。条件分岐は不要です。

2.1 Mastodon の表示を例に挙げて……

 Mastodon の公式レイアウトに近い次のような表示を考えます。

f:id:mntone:20190107155002p:plain
レイアウトサンプル

 まず layoutMargins が (15pt, 8pt) または (20pt, 8pt) になっています。これが外周の余白ですね。

 余計なコードを実行させたくないので,あらかじめアイコンのサイズは 48pt であると指定しておきます(xib で設定,またはコードベースで初期化している場合 frame のサイズに 48pt と必ず入れておく)

 あとはコンテンツの中身に従ってレイアウトしていきます。上の画像では xib で 4 枚レイアウトを想定して手動で配置していますw 画像枚数に従ってテキトーに配置するだけですね。

 それでできたコードが以下の通り

private let margin: CGFloat = 8
private let spacing: CGFloat = 4
private let imageHeight: CGFloat = 128
    
private func layout() {
    avaterImageView.pin
        .top(pin.layoutMargins)
        .start(pin.layoutMargins)
    nameLabel.pin
        .top(pin.layoutMargins)
        .marginStart(margin).after(of: avaterImageView)
        .sizeToFit()
    screenNameLabel.pin
        .marginLeft(spacing).after(of: nameLabel, aligned: .bottom)
        .sizeToFit()
        
    contentTextView.pin
        .marginTop(spacing).below(of: nameLabel)
        .marginStart(margin).after(of: avaterImageView)
        .end(pin.layoutMargins)
        .sizeToFit(.width)
        
    if imageCount == 1 { // 画像が1枚のときのみ掲載しています
        imageView1.pin
            .marginTop(spacing).below(of: contentTextView)
            .marginStart(margin).after(of: avaterImageView)
            .end(pin.layoutMargins)
            .height(imageHeight)
    }
}

 もちろんセルの中に入れて使いますので,セルの高さを返したりの処理はしないといけません。ここはちょっと面倒ですね。横幅は親の view 目一杯に使うので計算不要ですが,高さはきちんと返さないと UITableView が正しく配置できないので。

override func sizeThatFits(_ size: CGSize) -> CGSize {
    contentView.pin.width(size.width)
    layoutIfNeeded()
        
    var height: CGFloat = 0
    switch imageCount { // 適切に配置されていれば,テキストや画像の一番下の座標+余白で計算してやればいい。
    case 1: fallthrough
    case 2: fallthrough
    case 3:
        height = imageView1.frame.maxY
            
    case 4:
        height = imageView3.frame.maxY
            
    default:
        height = contentTextView.frame.maxY
    }
    height = max(height, avaterImageView.frame.maxY) + layoutMargins.bottom
    return CGSize(width: size.width, height: height)
}

 また,レイアウトを実際に行うタイミングへのハンドルをしないといけません

override func layoutSubviews() {
    super.layoutSubviews()
    layout()
}

2.2 実際に使ってて残念に思ったこと

 baseline を揃える機能が AutoLayout にはありますが,PinLayout には存在しないこと,この唯一一点がつらい。

 正直均等割り付けとかは全体の横幅から個々の幅に合わせて計算すればいいので,ほとんどのことが可能なのですが……

2.3 いいところもいっぱいある。

 AutoLayout は動的に変更する場合とても手続きが面倒です。

 しかし PinLayout の場合,あくまでレイアウトするタイミングで横幅を考慮して条件分岐できたりするので,ほとんどが思考のそのままにかけるところがいいと思います。

 例えばタブレットなどのリッチデバイスは 500pt を超えているので,ゆとりを持ったレイアウトに変更するのも if 文を仕込んでやればいいです。

 基本的に,setNeedsLayout() を呼び出す UIKit の風習のまま扱えるため,UIKit との親和性が高いところがとても気に入っていますし,何より複雑でない View なら AutoLayout を引き続き採用するといったこともできるので,選択肢が多いのがいいですね。

まとめ

  • PinLayout は動的変更が多い場合 AutoLayout よりも楽。
  • LTR も RTL もどちらも対応しようと思えばできる (Start, End を開発初期から積極的に使っていくべし)
  • AutoLayout は重い ← ここ重要
  • UIStackView は内部で AutoLayout に変換している ← ここも重要