iOS layoutMargins 的坑:一個活久見的 bug

戴倉薯發表於2018-03-18

神奇的效果

有天一回到座位上,張皇失措的應屆生同事就好像看到救星一樣把我抓過去:“倉薯倉薯,不好了,你看它這樣了!!”

我一看,從不說粗口的倉薯也忍不住說了一句:“我……去,我做了這麼多年 iOS 還從來沒遇見這樣的事。” 把領導也叫過來看。領導拿來玩了一會兒,然後說:“哈哈哈,感覺真想要實現這個效果,還不是那麼容易呢……”

究竟是什麼 bug 讓我們都這麼不淡定呢?看下面的 gif 就知道了:

shrink_bad_demo.gif

這個方塊形的 cell 就是一個平凡而普通的 collectionView 上平凡而普通的 collectionViewCell,很多地方都在用,用了一年多了,一直都長這個樣子,從沒出任何問題。然而被我們的應屆生同事不知道怎麼一改,出現了這樣的效果:當 cell 滾動到螢幕邊緣,即將離開螢幕的時候,它好像捨不得離開一樣,竟然把自己縮起來了……

要不要來幫我 debug

以下是能重現 bug 的程式碼,能在 iPhone 7 iOS 11 模擬器上重現。為了只寫一個檔案,我就把程式碼最簡化了,只要 60 行:

import UIKit

final class TestCell: UICollectionViewCell {

  override init(frame: CGRect) {
    let imageView = UIImageView(frame: .zero)
    let metadataView = UIView(frame: .zero)

    super.init(frame: frame)

    imageView.backgroundColor = UIColor.red
    metadataView.backgroundColor = UIColor.green

    for view in [imageView, metadataView] {
      addSubview(view)
      view.translatesAutoresizingMaskIntoConstraints = false
      view.leadingAnchor.constraint(equalTo: self.layoutMarginsGuide.leadingAnchor).isActive = true
      view.trailingAnchor.constraint(equalTo: self.layoutMarginsGuide.trailingAnchor).isActive = true
    }

    imageView.topAnchor.constraint(equalTo: self.layoutMarginsGuide.topAnchor).isActive = true
    imageView.widthAnchor.constraint(equalTo: imageView.heightAnchor).isActive = true

    metadataView.topAnchor.constraint(equalTo: imageView.bottomAnchor).isActive = true
    metadataView.heightAnchor.constraint(equalToConstant: 25).isActive = true
    metadataView.bottomAnchor.constraint(equalTo: self.layoutMarginsGuide.bottomAnchor).isActive = true
  }

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

final class ViewController: UICollectionViewController, UICollectionViewDelegateFlowLayout {

  override func viewDidLoad() {
    super.viewDidLoad()

    self.collectionView!.contentInsetAdjustmentBehavior = .never
    self.collectionView!.register(TestCell.self, forCellWithReuseIdentifier: "Cell")
  }

  // MARK: UICollectionViewDataSource

  override func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int {
    return 10
  }

  override func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
    return collectionView.dequeueReusableCell(withReuseIdentifier: "Cell", for: indexPath)
  }

  func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
    let measurementCell = TestCell()
    let width = (collectionView.bounds.size.width - 20) / 2.0
    measurementCell.widthAnchor.constraint(equalToConstant: width).isActive = true
    return CGSize(width: width, height: measurementCell.systemLayoutSizeFitting(UILayoutFittingCompressedSize).height)
  }
}
複製程式碼

約束用的是系統原生的寫法,可能大家平時用第三方庫用得多,原生寫法反而不熟悉了。簡單解釋下,假設紅色是圖片,綠色是描述吧:

  1. 圖片左邊、右邊、上面約束到父 view,高度 = 寬度
  2. 描述左邊、右邊、下面約束到父 view,高度固定 25,頂部貼著圖片底部

程式碼出來了,能看出是什麼問題嗎?

幾個猜測

Q:是不是 layout 出什麼問題了! A:用的是最簡單的 UICollectionViewFlowLayout 啊…… 沒 override 任何東西。

Q:是不是 constraint 衝突? A:你看我約束得有啥問題?明明不會有任何衝突耶。

Q:Cell size 算得不對吧? A:最普通的自動計算…… 打 log 來看算得是對的。而且,就算是出了問題,滾動的時候也不會實時計算 size 啊…… 它可是一邊滾一邊縮啊……

Q:view.leadingAnchor.constraint(equalTo: self.layoutMarginsGuide.leadingAnchor).isActive = true 這個self.layoutMarginsGuide.leadingAnchor是什麼鬼,你就不能用self.leadingAnchor嗎? A:你猜對了…… 因為想省事改 self.layoutMargins 所以約束到 layoutMarginsGuide,但確實如果改成約束到普通的self.leadingAnchor就不會有問題了。

Q:這貨是不是隻有什麼特定情況才有的 bug,比如 iOS 11 或者 iPhoneX A:沒錯是 iOS 11 才有……任何手機都可以重現,但確實跟 iPhoneX 有點關係……

這下聰明的讀者猜出是什麼問題了嗎?:)

其實就是少了一行

要解決這個問題很簡單,就是在 cell 的init方法里加一句

self.insetsLayoutMarginsFromSafeArea = false
複製程式碼

insetsLayoutMarginsFromSafeArea 這個屬性對於所有UIView預設為YES(我覺得這點並不是太科學),當它為YES的時候,view 的 layoutMargins 會根據 safeArea 進行調整。這樣的話,即使把 layoutMargins 設定為一個固定值比如 layoutMargins = .zero,但是到了螢幕邊緣的時候,它的 margins 還是會逐漸變大,本意應該是為了讓子 view 自動避開 iPhoneX 的劉海吧。這樣,出現上面這個效果神奇的 bug也不足為怪了。

Layout Margins 的好處和坑

這麼說的話,其實應該是個很常見的問題,為啥平常遇到的不多呢?我想還是因為我們約束到 layoutMarginsGuide 的情況比較少吧。

layoutMargins 這套東西用來改 insets 是非常方便的。比如我寫一個用途很廣泛的東西,希望能支援使用者隨意改動它的 insets,如果我不用 layoutMargins 的話,我需要維護 4 個 constraints:

// properties
var leadingInsetConstraint: NSLayoutConstraint!
var trailingInsetConstraint: NSLayoutConstraint!
var topConstraint: NSLayoutConstraint!
var bottomConstraint: NSLayoutConstraint!

// during init
self.leadingInsetConstraint = someView.leadingAnchor.constraint(equalTo: self.leadingAnchor)
self.leadingInsetConstraint.isActive = true
self.trailingInsetConstraint = someView.trailingAnchor.constraint(equalTo: self.trailingAnchor)
self.trailingInsetConstraint.isActive = true
self.topInsetConstraint = someView.topAnchor.constraint(equalTo: self.topAnchor)
self.topInsetConstraint.isActive = true
self.bottomInsetConstraint = someView.bottomAnchor.constraint(equalTo: self.bottomAnchor)
self.bottomInsetConstraint.isActive = true

// configuration
self.leadingInsetConstraint.constant = inset.left // 假設我們不考慮阿拉伯語吧
self.trailingInsetConstraint.constant = inset.right
self.topInsetConstraint.constant = inset.top
self.bottomInsetConstraint.constant = inset.bottom
複製程式碼

而如果我用layoutMagins這套東西,上面這些程式碼就可以簡化很多了,一個屬性都不用存:

// during init
self.leadingInsetConstraint = someView.leadingAnchor.constraint(equalTo: self.layoutMarginsGuide.leadingAnchor)
self.trailingInsetConstraint = someView.trailingAnchor.constraint(equalTo: self.layoutMarginsGuide.trailingAnchor)
self.topInsetConstraint = someView.topAnchor.constraint(equalTo: self.layoutMarginsGuide.topAnchor)
self.bottomInsetConstraint = someView.bottomAnchor.constraint(equalTo: self.layoutMarginsGuide.bottomAnchor)

// configuration
self.layoutMargins = insets
複製程式碼

如果使用 directionalLayoutMargins,連阿拉伯語的情況都自動處理好了。

但它也有一些坑,上面提到的就是其中之一。另外的我隨便列兩個:

  1. layoutMargins 的預設值居然不是 0。這一點讓我永遠都不能理解蘋果的腦回路,它的預設值是 UIEdgeInsets(8,8,8,8)。也許 8 是某個蘋果工程師的幸運數字吧……
  2. 沒有加進 view hierarchy 之前,佈局可能無法正確使用 layout margins。這一點就比較詭異,印象中以前就遇到需要先 addSubview 再設 layoutMargins,反過來就跟沒設一樣的神奇 bug,也不知道最新版的系統修好了沒有了……

相關文章