iOS
AutoLayout
Swift
TinyConstraints

コードでAutolayout-自動でセルの高さを変えるUITableView

コードでAutolayoutの4回目です

過去のはこちら
コードでAutoLayout-入門
コードでAutoLayout-モーダルビュー
コードでAutoLayout-余白を均等に配置する

storyboardを使わず全てコードで書いて制約をつけていきます
制約をつけるにはTinyConstraintsを使いますが別にNSLayoutAnchorでもなんでもいいです
どんな制約をつけているかはなんとなく分かるかと思います

非同期な画像の読み込みはKingfisherです

やりたいこと

Storyboard絶対使わないマン
アイコンとテキストがあるセルをつくる
セーフエリア内に収まる
アイコンはサーバーから取得する
セルの高さは自動計算される
テキストの長さ次第で制約を切り替える

表示

ソースコード

実務でコピペして使えるようにファイルやメソッドがたくさん分かれています

基底クラス

ソースだけでviewをつくるときの定番みたいなのを基底クラスにまとめてます
基本的にはこれらを継承していきdidInitメソッドに処理を足していきます

BaseView.swift
import UIKit

class BaseView:UIView{
  convenience init(){self.init(frame: .zero)}
  override init(frame: CGRect) {
    super.init(frame: frame)
    didInit()
  }

  required init?(coder aDecoder: NSCoder) {
    fatalError("init(coder:) has not been implemented")
  }

  func didInit(){}  
}
BaseTableViewCell.swift
import UIKit

class BaseTableViewCell:UITableViewCell{
  override init(style: UITableViewCellStyle, reuseIdentifier: String?) {
    super.init(style: style, reuseIdentifier: reuseIdentifier)
    didInit()
  }

  required init?(coder aDecoder: NSCoder) {
    fatalError("init(coder:) has not been implemented")
  }

  func didInit(){}
}

MainTableViewControllerとMainTableView

コントローラーに、画面一般に広がるビューと、セーフエリア内に収まるメインのビューをふたつ用意します

MainTableViewController.swift
import UIKit
import TinyConstraints

//テストデータ
let items:[Any]=[
  ["text":"ティーン雑誌『ニコラ』のモデルとして活躍後、女優業を中心に活動。2006年放送のCMで、注目を浴びる。主演映画「恋空」は大ヒットを記録し、その年に数々の映画賞を受賞。近年の主演映画に「トワイライト ささらさや」、「くちびるに歌を」がある。ドラマ「全開ガール」(2011・フジテレビ)では連続ドラマ初主演を果たす。その後「リーガル・ハイ」(2012・2013・2014・フジテレビ)、「掟上今日子の備忘録」(主演/2015・日本テレビ)、「逃げるは恥だが役に立つ」(2016・TBS)等に出演。その他数々のCMに出演中!"
    ,"image": "https://www.lespros.co.jp/files/talent/4/profile.jpg"
  ]
  ,["text":"あいうえお"
    ,"image": "http://cdn.buzz-plus.com/wp-content/uploads/2018/01/gakky2.jpg"
  ]
  ,["text":"女優の新垣結衣と俳優の瑛太がW主演する映画『ミックス。』(10月21日公開)の公式Instagramが28日、更新。革ジャン姿の新垣を紹介し、ネット上で大きな反響を呼んでいる。"
    ,"image": "https://cdn.amebaowndme.com/madrid-prd/madrid-web/images/sites/121508/1a28edd6c91d5cba3a4c68e8b044f398_37283de1b258a0023a64e54c0f8c09d7.jpg"
  ]
  ,["text":"かきくけこ"
    ,"image": "https://d17gj49471obkc.cloudfront.net/kyodopress_cms/wp-content/uploads/2018/02/15s_C6-640x360-430x242.jpg"
  ]
  ,["text":"ドラマ「逃げるは恥だが役に立つ」の大ヒットが記憶に新しい新垣結衣さん。今や主演女優として大人気の新垣結衣さんですが、新垣結衣さんはどんな性格をしているのでしょうか?売れっ子女優さんですと、良くない噂が立つこともありますが、新垣結衣さんは実は性格が良いと評判なのだそうです。",
    "image": "https://scontent-nrt1-1.cdninstagram.com/vp/a458af457d59f256469df110d1b11ff9/5B984228/t51.2885-15/s640x640/sh0.08/e35/18722645_224656501361094_4889530270304174080_n.jpg"
  ]
  ,["text":"ティーン雑誌『ニコラ』のモデルとして活躍後、女優業を中心に活動。2006年放送のCMで、注目を浴びる。主演映画「恋空」は大ヒットを記録し、その年に数々の映画賞を受賞。近年の主演映画に「トワイライト ささらさや」、「くちびるに歌を」がある。ドラマ「全開ガール」(2011・フジテレビ)では連続ドラマ初主演を果たす。その後「リーガル・ハイ」(2012・2013・2014・フジテレビ)、「掟上今日子の備忘録」(主演/2015・日本テレビ)、「逃げるは恥だが役に立つ」(2016・TBS)等に出演。その他数々のCMに出演中!"
    ,"image": "https://www.lespros.co.jp/files/talent/4/profile.jpg"
  ]
  ,["text":"あいうえお"
    ,"image": "http://cdn.buzz-plus.com/wp-content/uploads/2018/01/gakky2.jpg"
  ]
  ,["text":"女優の新垣結衣と俳優の瑛太がW主演する映画『ミックス。』(10月21日公開)の公式Instagramが28日、更新。革ジャン姿の新垣を紹介し、ネット上で大きな反響を呼んでいる。"
    ,"image": "https://cdn.amebaowndme.com/madrid-prd/madrid-web/images/sites/121508/1a28edd6c91d5cba3a4c68e8b044f398_37283de1b258a0023a64e54c0f8c09d7.jpg"
  ]
  ,["text":"かきくけこ"
    ,"image": "https://d17gj49471obkc.cloudfront.net/kyodopress_cms/wp-content/uploads/2018/02/15s_C6-640x360-430x242.jpg"
  ]
  ,["text":"ドラマ「逃げるは恥だが役に立つ」の大ヒットが記憶に新しい新垣結衣さん。今や主演女優として大人気の新垣結衣さんですが、新垣結衣さんはどんな性格をしているのでしょうか?売れっ子女優さんですと、良くない噂が立つこともありますが、新垣結衣さんは実は性格が良いと評判なのだそうです。",
    "image": "https://scontent-nrt1-1.cdninstagram.com/vp/a458af457d59f256469df110d1b11ff9/5B984228/t51.2885-15/s640x640/sh0.08/e35/18722645_224656501361094_4889530270304174080_n.jpg"
  ]
  ,["text":"ティーン雑誌『ニコラ』のモデルとして活躍後、女優業を中心に活動。2006年放送のCMで、注目を浴びる。主演映画「恋空」は大ヒットを記録し、その年に数々の映画賞を受賞。近年の主演映画に「トワイライト ささらさや」、「くちびるに歌を」がある。ドラマ「全開ガール」(2011・フジテレビ)では連続ドラマ初主演を果たす。その後「リーガル・ハイ」(2012・2013・2014・フジテレビ)、「掟上今日子の備忘録」(主演/2015・日本テレビ)、「逃げるは恥だが役に立つ」(2016・TBS)等に出演。その他数々のCMに出演中!"
    ,"image": "https://www.lespros.co.jp/files/talent/4/profile.jpg"
  ]
  ,["text":"あいうえお"
    ,"image": "http://cdn.buzz-plus.com/wp-content/uploads/2018/01/gakky2.jpg"
  ]
  ,["text":"女優の新垣結衣と俳優の瑛太がW主演する映画『ミックス。』(10月21日公開)の公式Instagramが28日、更新。革ジャン姿の新垣を紹介し、ネット上で大きな反響を呼んでいる。"
    ,"image": "https://cdn.amebaowndme.com/madrid-prd/madrid-web/images/sites/121508/1a28edd6c91d5cba3a4c68e8b044f398_37283de1b258a0023a64e54c0f8c09d7.jpg"
  ]
  ,["text":"かきくけこ"
    ,"image": "https://d17gj49471obkc.cloudfront.net/kyodopress_cms/wp-content/uploads/2018/02/15s_C6-640x360-430x242.jpg"
  ]
  ,["text":"ドラマ「逃げるは恥だが役に立つ」の大ヒットが記憶に新しい新垣結衣さん。今や主演女優として大人気の新垣結衣さんですが、新垣結衣さんはどんな性格をしているのでしょうか?売れっ子女優さんですと、良くない噂が立つこともありますが、新垣結衣さんは実は性格が良いと評判なのだそうです。",
    "image": "https://scontent-nrt1-1.cdninstagram.com/vp/a458af457d59f256469df110d1b11ff9/5B984228/t51.2885-15/s640x640/sh0.08/e35/18722645_224656501361094_4889530270304174080_n.jpg"
  ]
]

class MainTableViewController:UIViewController{
  //画面いっぱいにひろがるビュー、主に背景色を表示するだけの役割
  var baseView:UIView?
  //メインのテーブルビュー
  var tableView:MainTableView?


  override func viewDidLoad() {
    super.viewDidLoad()
    //ビューを全て読み込み、、、
    self.setup()
    //制約をつけて、、、
    self.setupConstraints()
    //データをロードする
    self.loadData()
  }

  //viewを読み込むメソッド
  //制約以外の処理はここで書き込む
  private func setup(){
    do{
      let baseView = UIView()
      self.view.addSubview(baseView)
      self.baseView = baseView
      baseView.backgroundColor = .white
    }
    do{
      let tableView = MainTableView()
      self.view.addSubview(tableView)
      self.tableView = tableView
      tableView.backgroundColor = .white
    }
  }

  //制約をつけるメソッド
  private func setupConstraints(){
    guard let baseView = self.baseView
      ,let tableView = self.tableView else {
      return
    }
    do{
      //画面いっぱいに収まるようにする
      baseView.edgesToSuperview()
    }
    do{
      //セーフエリアに収まるようにする
      tableView.edgesToSuperview(usingSafeArea:true)
    }
  }

  //データを読み込むメソッド
  private func loadData(){
    guard let tableView = self.tableView else {return}
    //本来はサーバーからデータを取得する
    tableView.items = items
  }  
}
MainTableView.swift
import UIKit
import TinyConstraints
import Kingfisher

class MainTableView: BaseView{

  //読み込むセルをエイリアスで登録しておく
  //セルを入れ替えたい時はここを替えるだけでいい
  typealias Cell = CustomTableViewCell
  let CELL_CLASS = Cell.self
  let CELL_ID = NSStringFromClass(Cell.self)


  private var tableView:UITableView?

  //コントローラーからデータをもらったらtableViewをreloadする
  var items:[Any]?{
    didSet{
      self.tableView?.reloadData()
    }
  }

  override func didInit() {
    super.didInit()
    //ビューを全て読み込み、、、
    self.setup()
    //制約をつける
    self.setupConstraints()
  }

  //viewを読み込むメソッド
  //制約以外の処理はここで書き込む
  private func setup(){
    do{
      let tableView = UITableView()
      self.addSubview(tableView)
      self.tableView = tableView
      tableView.register(CELL_CLASS, forCellReuseIdentifier: CELL_ID)
      tableView.delegate = self
      tableView.dataSource = self
    }
  }

  //制約をつけるメソッド  
  private func setupConstraints(){
    guard let tableView = self.tableView else { return }
    //親ビューいっぱいに広げる
    tableView.edgesToSuperview()
  }  
}


//UITableViewDelegate, UITableViewDataSource
extension MainTableView:UITableViewDelegate, UITableViewDataSource{

  func numberOfSections(in tableView: UITableView) -> Int {
    return 1
  }

  func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
    return items?.count ?? 0
  }

  func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
    guard let cell = tableView.dequeueReusableCell(withIdentifier: CELL_ID) as? Cell else {
      fatalError("cell error")
    }
    if
      let data = self.items?[indexPath.row] as? [String: String]
      ,let text = data["text"]
      ,let imgURLStr = data["image"]
      ,let imgURL = URL(string: imgURLStr){

      //後述するセルのLabelとImageViewにデータを代入
      cell.mainView?.lbl?.text = text
      cell.mainView?.imageView?.kf.setImage(with:imgURL)
      cell.mainView?.relayout()
    }
    return cell
  }
}

CustomTableViewCellとCustomTableViewCellView

MainTableViewが読み込むセルと、そのセルが表示するビューです

CustomTableViewCell.swift
import UIKit
import TinyConstraints

class CustomTableViewCell:BaseTableViewCell{
  //セルが読み込むメインのビュー
  //外部データを読み込むためpublic
  var mainView:CustomTableViewCellView?

  override func didInit() {
    super.didInit()
    //ビューを全て読み込み、、、
    self.setup()
    //制約をつける
    self.setupConstraints()
  }

  //viewを読み込むメソッド
  //制約以外の処理はここで書き込む
  private func setup(){
    do{
      let mainView = CustomTableViewCellView()
      self.addSubview(mainView)
      self.mainView = mainView
    }
  }

  //制約をつけるメソッド  
  //自身の制約はつけなくていいらしい、たぶん
  private func setupConstraints(){
    guard let mainView = self.mainView else {return}
    //親ビューいっぱいに広げる
    mainView.edgesToSuperview()
  }  
}

ようやくメインとなるビューです
重要な制約はここでつけてます
制約としては「ラベルの高さに合わせてセルの高さが変わる、かつ、ラベルの高さが短すぎる場合は最小のセルの高さにする」という感じです

CustomTableViewCellView.swift
import UIKit
import TinyConstraints

class CustomTableViewCellView:BaseView{

  //bottom用のふたつの制約
  private var bottomToImageView:Constraint?
  private var bottomToLabel:Constraint?


  //外部データを代入するためpublic
  var imageView:UIImageView?
  var lbl:UILabel?

  override func didInit() {
    super.didInit()
    //ビューを全て読み込み、、、
    self.setup()
    //制約をつける
    self.setupConstraints()
  }

  //viewを読み込むメソッド
  //制約以外の処理はここで書き込む
  private func setup(){
    do{
      let minHeightView = UIView()
      self.addSubview(minHeightView)
      self.minHeightView = minHeightView
    }
    do{
      let imageView = UIImageView()
      self.addSubview(imageView)
      self.imageView = imageView
      imageView.contentMode = .scaleAspectFill
    }
    do{
      let lbl = UILabel()
      self.addSubview(lbl)
      self.lbl = lbl
      //これよく忘れる...
      lbl.numberOfLines = 0
    }
  }

  //制約をつけるメソッド  
  private func setupConstraints(){
    guard
       let imageView = self.imageView
      ,let lbl = self.lbl
      else{
        return
    }

    //自身の幅を決める
    //プライオリティを指定しないと制約エラーをはく
    self.width(UIScreen.main.bounds.width, priority: .defaultHigh)

    do{
      //親ビューの左上
      imageView.left(to: self, offset: 5)
      imageView.top(to: self, offset: 5)

      //幅を親ビューの幅の0.25倍
      imageView.width(to: self, multiplier:0.25)
      //高さを自身の幅に合わせる(正方形になる)
      imageView.height(to: imageView, imageView.widthAnchor)

      //アイコンを丸くするのに画像の幅を知る必要があるためレイアウトを更新する
      imageView.setNeedsLayout()
      imageView.layoutIfNeeded()
      imageView.layer.masksToBounds = true
      imageView.layer.cornerRadius = imageView.bounds.width/2
    }
    do{
      //アイコンの位置に合わせて制約をつける
      lbl.leftToRight(of: imageView, offset:5)
      lbl.right(to: self, offset: -5)
      lbl.top(to: imageView)
    }

    //コンテンツの高さに合わせて制約を切り替えるため二つ用意
    //自身のbottomをアイコンに合わせた方の制約
    self.bottomToImageView = self.bottom(to: imageView, offset: 5, priority: .defaultHigh, isActive: false)
    //自身のbottomをラベルに合わせた方の制約
    self.bottomToLabel = self.bottom(to: lbl, offset: 5, priority: .defaultHigh, isActive: false)
  }

    func relayout(){
    guard
    let imageView = self.imageView
    ,let lbl = self.lbl
      else{
        return
    }
    //ラベルの高さを更新
    lbl.setNeedsLayout()
    lbl.layoutIfNeeded()

    //ラベルとアイコンの高さに合わせて制約を切り替える

    self.bottomToImageView?.isActive = false
    self.bottomToLabel?.isActive = false

    if imageView.frame.maxY > lbl.frame.maxY{
        self.bottomToImageView?.isActive = true
    }
    else{
        self.bottomToLabel?.isActive = true
    }

    self.layoutIfNeeded()
  }
}

おわり

長く感じますが、実務で使ってる感じで汎用性とか可読性とかもろもろ考慮したらこの書き方に落ち着きました。
エッセンスだけ抜き出せばもっと短く書けるはず???

たくさんファイルを分けた弊害としてやたらとpublicな変数が出てきます、カプセル化とかを考えたらよろしくないし代入用のメソッド用意したりすればprivateにできるのですが、呼び出すクラスは決まってるし変数が増える度にあっちこっち書き足す方がデメリット大きいと判断して(要はめんどうだから)publicにしちゃってます。。

あとpriorityつけなくちゃいけない時がよく分かってないです、今のところエラーが出たらつけるという曖昧なノリ。。。

このコードでは起きてませんが、スクロールがカクつくとかずれるとか何か問題が起きたら教えてください
というか、そもそももっと楽なやり方あるYO!ってもの教えてください