投稿日:

【Swift】コードでAutoLayout実装をしやすくするために工夫したこと

xcode ios autolayout コード




衝撃的な話から入りますが、なんとiOS開発歴4年目でAutoLayoutデビューを果たしました(最近の話)。

ようやく重い腰を上げたという感じですが、ぎっくり腰だったんじゃないかと思いたくなるほどで、コードを書く者としては罪深いことをしてしまったような気がしています。

さて、まず知識的な勉強から始めたわけですが、StoryboardによるGUI的なやり方はどうも手をつける気持ちになれませんでした。
なんというか「コード」と言う名の引力が僕を引っ張っている感じなんです。(?)








VisualFormat無用論


コードによるAutoLayoutには
constraintsWithVisualFormat()

NSLayoutConstraint(item:...)
の2種類があることがわかりました。

視覚的に見やすいじゃん!という理由からvisualFormatで実際にある程度の量のViewをレイアウトしてみましたが、
「あ、ダメだわこれ」となり、VisualFormatは却下する運びとなりました。

その理由は以下。

・一見柔軟性があるように見え、View数が増えるほど保守が困難になる
・ViewのDictionaryのKey名とformat内のView名を揃えないといけない
・共通String,CGFloatを\()使って組み込んだところ、視認性が悪化
・RunするまでFormatの文法ミスに気づきにくい

などなど。



視認性の高さがメリットだと思って飛びついたのにその悪化っぷりは凄まじいものでした。

visualConstraints()という簡易入力メソッド、
Page1ConstraintKeyName()というDictionaryのKey名とformat内のView名を共通化するためのstruct、
そしてKey名:Viewオブジェクトを格納するself.page1Subviews辞書と、
快適性と保守性を考慮した準備をして複数のViewをレイアウト(垂直方向)したところ、下記のように非常に見づらくなってしまいました。


let key = Page1ConstraintKeyName()

let constraintVertical = visualConstraints("V:|-800-[\(key.AboutApp1Image)(204)]-\(aboutApp2Top)-[\(key.AboutApp2Image)(200)]-\(aboutApp3Top)-[\(key.AboutApp3Image)(203)]-180-[\(key.LargeTitleLanguage)(20)]-60-[\(key.LanguageImage)(211.5)]-50-[\(key.DetailLanguage)]-2000-|", 
views: self.page1Subviews)



これでもまだViewサイズが数字の直接入力ですからね。

視認性と柔軟性があれば最初の学習コストは問題ないでしょうが、僕には使うメリットは感じられませんでした。



2つ目の選択肢


となると残るはNSLayoutConstraint(item:...)のみとなります。

しかし以下のコードを見てわかる通り、とにかくコードが冗長なんです。


let labelTop = NSLayoutConstraint(
            item: label1, 
            attribute: .Top , 
            relatedBy: .Equal, 
            toItem: label1.superview, 
            attribute: .Top, 
            multiplier: 1.0, 
            constant: 40)


しかもこれでやっと1つの属性をセットしただけです。
frameであればCGRectMake(0, 0, 0, 0)と1行で一気に設定できてしまうことを考えればあまりに長いです。



自前関数で対策してみた


そこで頭に浮かんだのが、上記のメソッドの元になっているこちらの式

item.attribute = toItem.attribute * multiplier + constant

みたいに入力できれば楽じゃないかということです。

(プログラミングは自前でツールを作れるということを一瞬忘れていました)


そして結論から言うと、作った関数がこちら。

self.layoutRequired(.Width, isEqual: image.attrWidth(), multiply: 1, plus: 50)



最初のselfはレイアウトするView自身で、そのViewのクラス内で実行しています。
Requiredというのはpriorityの種類を指します(後述)。

引数名などに(プログラミング的に)難があるかもしれませんが、式をベースに1行程度で収まるようにしました。



具体的なコード


すべてUIViewのエクステンションに作成してます。

まずはisEqual:引数で使うための属性プロパティです。
タプルでView自身と対応したNSLayoutAttributeを返します。

extension UIView {
// MARK: NSLayoutAttribute関数
    
    func attrLeft() -> (UIView?, NSLayoutAttribute) {
        return (self, NSLayoutAttribute.Left)
    }
    
    func attrRight() -> (UIView?, NSLayoutAttribute) {
        return (self, NSLayoutAttribute.Right)
    }
    
    func attrTop() -> (UIView?, NSLayoutAttribute) {
        return (self, NSLayoutAttribute.Top)
    }
    
    func attrBottom() -> (UIView?, NSLayoutAttribute) {
        return (self, NSLayoutAttribute.Bottom)
    }
    
    func attrLeading() -> (UIView?, NSLayoutAttribute) {
        return (self, NSLayoutAttribute.Leading)
    }
    
    func attrTrailing() -> (UIView?, NSLayoutAttribute) {
        return (self, NSLayoutAttribute.Trailing)
    }

    func attrWidth() -> (UIView?, NSLayoutAttribute) {
        return (self, NSLayoutAttribute.Width)
    }
    
    func attrHeight() -> (UIView?, NSLayoutAttribute) {
        return (self, NSLayoutAttribute.Height)
    }
    
    func attrCenterX() -> (UIView?, NSLayoutAttribute) {
        return (self, NSLayoutAttribute.CenterX)
    }
    
    func attrCenterY() -> (UIView?, NSLayoutAttribute) {
        return (self, NSLayoutAttribute.CenterY)
    }
    
    func attrBaseline() -> (UIView?, NSLayoutAttribute) {
        return (self, NSLayoutAttribute.Baseline)
    }
}



続いて先ほどご紹介したレイアウトメソッドです。
priorityを引数で指定するようにすると冗長になるのでメソッド別にしました。


extension UIView {
// priority:Requiredでconstraintを追加する。
func layoutRequired(attributeL: NSLayoutAttribute, isEqual: (UIView?, NSLayoutAttribute), multiply: CGFloat, plus: CGFloat) {

    pr_HandleConstraintCreation(attributeL, isEqual: isEqual, multiply: multiply, plus: plus, priority: UILayoutPriorityRequired)

}
    
// priority:Lowでconstraintを追加する。
func layoutLow(attributeL: NSLayoutAttribute, isEqual: (UIView?, NSLayoutAttribute), multiply: CGFloat, plus: CGFloat) {

    pr_HandleConstraintCreation(attributeL, isEqual: isEqual, multiply: multiply, plus: plus, priority: UILayoutPriorityDefaultLow)

}
    
// priority:Highでconstraintを追加する。
func layoutHigh(attributeL: NSLayoutAttribute, isEqual: (UIView?, NSLayoutAttribute), multiply: CGFloat, plus: CGFloat) {

    pr_HandleConstraintCreation(attributeL, isEqual: isEqual, multiply: multiply, plus: plus, priority: UILayoutPriorityDefaultHigh)

}

// Constraintを生成する。
private func pr_HandleConstraintCreation(attributeL: NSLayoutAttribute, isEqual: (UIView?, NSLayoutAttribute), multiply: CGFloat, plus: CGFloat, priority: UILayoutPriority) {

   let (toItemView, attributeR) = pr_GetEqualViewAttribute(isEqual, attributeL: attributeL)

   let constraint = NSLayoutConstraint(item: self, attribute: attributeL, relatedBy: .Equal, toItem: toItemView, attribute: attributeR, multiplier: multiply, constant: plus)
   constraint.priority = priority

   self.superview!.addConstraints([constraint])

}

}



widthやheightの場合はtoItemをnilにしたいときも頻繁にあります。
そこでisEqual:self.attrNil()を渡し、メソッド内で判別することで手軽にnil指定できるようにしました。

// .Leftはダミー
func attrNil() -> (UIView?, NSLayoutAttribute) {
   return (nil, NSLayoutAttribute.Left)
}

// 対象のitemがnilかどうかによって要素を変える。pr_HandleConstraintCreation()から呼び出す
private func pr_GetEqualViewAttribute(equalElements: (UIView?, NSLayoutAttribute), attributeL: NSLayoutAttribute) -> (UIView?, NSLayoutAttribute) {

   let (toItemView, _) = equalElements
   return toItemView == nil ? (nil, attributeL) : equalElements

}




これで以下のように4行程度(例えばcenterX・top・width・height)で一通りのレイアウト設定ができるようになります。

self.layoutRequired(.CenterX, isEqual: image.attrCenterX(), multiply: 1, plus: 0)

self.layoutRequired(.Top, isEqual: image.attrBottom(), multiply: 1, plus: 40)

self.layoutRequired(.Width, isEqual: image.attrWidth(), multiply: 1, plus: 50)

self.layoutLow(.Height, isEqual: self.attrNil(), multiply: 1, plus: 100)



UILabelのsizeToFit()の実現


調べてみると、AutoLayoutではUILabelのsizeToFit()が使えないことがわかりました。

スタックオーバーフローの回答を参考にsizeToFit()に相当するメソッドを作成しました。

extension UILabel {

func sizeToFitWithAutoLayout(preferredMaxWidth: CGFloat) {
   self.setContentCompressionResistancePriority(UILayoutPriorityRequired, forAxis: .Vertical)
   self.setContentHuggingPriority(UILayoutPriorityRequired, forAxis: .Vertical)
   self.preferredMaxLayoutWidth = preferredMaxWidth
}
}



これでheightのconstraintのpriorityをLowに設定したうえで呼び出せば、sizeToFit()と同等のレイアウトが可能です。

// UILabelのサブクラス内で呼び出し
self.layoutLow(.Height, isEqual: image.attrHeight(), multiply: 1, plus: 0)

self.sizeToFitWithAutoLayout(0)

これらの工夫でAutoLayoutの実装がかなりしやすくなりました。
めでたしめでたし。



UIScrollViewのcontentSizeのセット


これらの方法でconstraintを追加していってもUIScrollViewがスクロールしませんでした(VisualFormatでは動きました)。

調べてみたところ、AutoLayoutの実行が完了したviewDidAppear()であればscrollView.contentSizeへの代入が機能することがわかりました。

intrinsicContentSize()も気になっているものの、とりあえずこれで様子見してみようと思います。




Sponsored Link





Comment