iOS layoutMargins 的坑:一個活久見的 bug
神奇的效果
有天一回到座位上,張皇失措的應屆生同事就好像看到救星一樣把我抓過去:“倉薯,不好了,你看它這樣了!!”
我一看,從不說粗口的倉薯也忍不住說了一句:“我……去,我做了這麼多年 iOS 還從來沒遇見這樣的事。” 把領導也叫過來看。領導拿來玩了一會兒,然後說:“哈哈哈,感覺真想要實現這個效果,還不是那麼容易呢……”
究竟是什麼 bug 讓我們都這麼不淡定呢?看下面的 gif 就知道了:
這個方塊形的 cell 就是一個平凡而普通的 collectionView 上平凡而普通的 collectionViewCell,很多地方都在用,用了一年多了,一直都長這個樣子,從沒出任何問題。然而被我們的應屆生同事不知道怎麼一改,出現了這樣的效果:當 cell 滾動到螢幕邊緣,即將離開螢幕的時候,它好像捨不得離開一樣,竟然把自己縮起來了……
要不要來幫我 debug
以下是能重現 bug 的程式碼,能在 iPhone 7 iOS 11 模擬器上重現。為了只寫一個檔案,我就把程式碼最簡化了,只要 60 行:
1234567891011121314151617181920212223242526272829303132333435363738394041424344 | 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) } } |
約束用的是系統原生的寫法,可能大家平時用第三方庫用得多,原生寫法反而不熟悉了。簡單解釋下,假設紅色是圖片,綠色是描述吧:
圖片左邊、右邊、上面約束到父 view,高度 = 寬度
描述左邊、右邊、下面約束到父 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方法里加一句
1 | 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:
12345678910111213141516171819 | // 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 =
|