見出し画像

車輪を再発明する: NSLayoutAnchorをラップしたクラスを作る

こんにちは、アプリ開発者の安藤ひつじです。今日はiOS/macOSアプリ開発で使えるNSLayoutAnchorをラップして、SnapKitのように使い心地の良いラッパークラスを作ってみたいと思います。

Storyboardはオワコン(?)

iOSアプリやmacOSアプリの画面を作るとき、GUIで直感的に作れるツール: Storyboardが昔から使われてきました。しかし、Storyboardを使ってGUIで画面をレイアウトするより、プログラムでレイアウトを作る方がエンジニアにとっては扱いやすいです。Storyboardはgitで変更を管理しづらい問題もあります。

「Storyboardのコンフリクト解消は厄介!」

エンジニアはプログラムで書かれていることに安心感を覚えます😃。少なくとも、僕の中でStoryboardを使う機会はかなり減ってきました。

レイアウトをプログラミングする

プログラムでレイアウトを作る最も単純な方法は、Viewのx,y,width,heightをガリガリ計算して配置していく方法です。しかし、この方法は様々な画面サイズに対応するには辛いところがあります。
そこで、ルール(制約)によってレイアウトする方法が使えます。例えば、「画面上部、水平方向真ん中に配置」など、ルールで記述する方法は、様々な画面サイズに柔軟に対応できます。このルールはNSLayoutConstraintを使って記述できます。しかし、NSLayoutConstraintは書き方がけっこう分かりにくい問題がありました。

NSLayoutAnchorの登場

NSLayoutConstraintのバージョンアップ版としてNSLayoutAnchorがiOS9で登場しました。NSLayoutAnchorの登場によりNSLayoutConstraintの煩雑な書き方から解放され、レイアウトのコーディングがかなり容易になりました。しかし、NSLayoutAnchorはその書き方にモヤモヤするところがあります。例えば、親Viewと四辺が一致するように子Viewを配置する場合、以下のように書きます。

parent.addSubview(child)

child.topAnchor.constraint(equalTo: parent.topAnchor).isActive = true
child.leftAnchor.constraint(equalTo: parent.leftAnchor).isActive = true
child.bottomAnchor.constraint(equalTo: parent.bottomAnchor).isActive = true
child.rightAnchor.constraint(equalTo: parent.rightAnchor).isActive = true

constraintごとにisActive = trueと決まったおまじないを書かなければいけません。(実際はconstraint(equalTo:)メソッドはNSLayoutConstraintを生成しているだけなので、NSLayoutConstraintの問題ですが)。あるいは以下のように書けます。

parent.addSubview(child)

NSLayoutConstraint.activate([
    child.topAnchor.constraint(equalTo: parent.topAnchor),
    child.leftAnchor.constraint(equalTo: parent.leftAnchor),
    child.bottomAnchor.constraint(equalTo: parent.bottomAnchor),
    child.rightAnchor.constraint(equalTo: parent.rightAnchor)
])

constraintの配列を渡して、まとめてアクティベートしてくれます。この方がまだ良いですね。
ところでNSLayoutAnchorを使うにあたり、以下の設定をしておかないと正しくレイアウトできません。この設定をときどき忘れてしまい、無駄にデバッグに時間を費やしてしまうことがありました。

child.translatesAutoresizingMaskIntoConstraints = false

SnapKitが便利

ここまでNSLayoutAnchorの話をしてきましたが、プログラムでレイアウトをするならSnapKitが便利です。iOSにもmacOSにも対応しています。


先程の例ならば以下のように書けます。

parent.addSubview(child)

child.snp.makeConstraints { make in
    make.edges.equalToSuperview()
}

かなり簡潔に書けますね。SnapKitが使えるならば、これを使うのが良さそうです。僕も多くのプロジェクトで使っています。ただ今回はSnapKitの紹介で終わってはつまらないので、NSLayoutAnchorをラップしてSnapKitのように使い心地の良いラッパークラスを自作してみたいと思います。車輪の再発明は嫌いじゃないです。

作ったクラスの利用例

まず作ったラッパークラスの利用例を以下に載せます。

let scrollViewNSScrollView
let nextButtonNSButton
let prevButtonNSButton

...

view.addSubview(scrollView)
view.addSubview(nextButton)
view.addSubview(prevButton)

scrollView.layoutConstraint
    .edges.equalToSuperview()
nextButton.layoutConstraint
    .centerY.equalToSuperview()
    .right.equalToSuperview()
    .size.equalTo(32)
prevButton.layoutConstraint
    .centerY.equalToSuperview()
    .left.equalToSuperview()
    .size.equalTo(32)

メソッドチェーンでつなげて書いていくことができます。equalToSuperview()など全体的にSnapKitの影響を受けています。上記のコードは以下のレイアウトになります。

画像1

実装

では実装に入ります。まずはimport文から。macOSとiOSどちらにも対応させるためにAppKitとUIKitをそれぞれ#ifで場合分けしてインポートします。

#if os(OSX)
import AppKit
#elseif os(iOS)
import UIKit
#endif

NSViewとUIViewの違いを意識しなくて済むようにtypealiasを作ります。iOSしか興味がないならば、このステップはスキップして構いません。(今後出てくるViewをUIViewに置き換えて読んでください)

#if os(OSX)
public typealias View = NSView
#elseif os(iOS)
public typealias View = UIView
#endif

LayoutConstraintクラスを作ります。initで忘れがちなtranslatesAutoresizingMaskIntoConstraints = falseをしておきましょう。LayoutConstraintEdgesはこの後出てきます。

public class LayoutConstraint {
    private weak var view: View?

    public var edges: LayoutConstraintEdges {
        LayoutConstraintEdges(view, self)
    }

    init(_ view: View) {
        self.view = view
        self.view?.translatesAutoresizingMaskIntoConstraints = false
    }
}

僕は.(ドット)記法でつなげて書けるのが好きなので、ViewのExtensionを作ります。

public extension View {
    var layoutConstraint: LayoutConstraint {
        LayoutConstraint(self)
    }
}

これでscrollView.layoutConstraint.edgesと書けるようになりました。

次にLayoutConstraintEdgesクラスを作ります。EdgesはViewの四辺に制約を設定します。ここではLayoutConstraintクラスと同じファイルに書くことにします。このクラスはLayoutConstraint内でしか生成しないため、initのアクセス修飾子はfileprivateにしておきます。

public class LayoutConstraintEdges {
    private weak var view: View?
    private let constraint: LayoutConstraint

    fileprivate init(_ view: View?_ constraint: LayoutConstraint) {
        self.view = view
        self.constraint = constraint
    }

    @discardableResult
    public func equalToSuperview(padding: CGFloat = 0) -> LayoutConstraint {
        guard let superview = view?.superview else { return constraint }
        return equalTo(superview, padding: padding)
    }

    @discardableResult
    public func equalTo(_ aView: View, padding: CGFloat = 0) -> LayoutConstraint {
        view?.topAnchor.constraint(equalTo: aView.topAnchor, constant: padding).isActive = true
        view?.leftAnchor.constraint(equalTo: aView.leftAnchor, constant: padding).isActive = true
        view?.bottomAnchor.constraint(equalTo: aView.bottomAnchor, constant: -padding).isActive = true
        view?.rightAnchor.constraint(equalTo: aView.rightAnchor, constant: -padding).isActive = true
        return constraint
    }
}

equalTo(_:padding:)メソッド内でtop、left、bottom、rightのNSLayoutAnchorを設定しています。第2引数のpaddingはデフォルト0の省略可能な引数です。NSLayoutAnchorのconstraint(equalTo:constant:)メソッドのconstant引数はoffsetを表しており、X軸、Y軸正方向に見てセットする値のため、bottomとrightにpaddingを付ける場合は負の値をセットします。equalToSuperview(padding:)メソッドは内部でviewから取れるsuperviewをequalTo(_:padding:)メソッドに渡すだけの簡単なメソッドです。なお、@discardableResultアノテーションを付けていないと、Xcodeさんに「返り値が未使用ですよ!」と警告されてしまいます。
さて、これでscrollView.layoutConstraint.edges.equalToSuperview() と書けるようになりました。

layoutConstraint.centerYやlayoutConstraint.right、layoutConstraint.leftについても同じように実装できますので割愛します。

最後にLayoutConstraintSizeクラスを作ります。作り方は大体同じです。

class LayoutConstraintSize {
    private weak var view: View?
    private let constraint: LayoutConstraint

    fileprivate init(_ view: View?_ constraint: LayoutConstraint) {
        self.view = view
        self.constraint = constraint
    }

    @discardableResult
    public func equalToSuperview() -> LayoutConstraint {
        guard let superview = view?.superview else { return constraint }
        return equalTo(superview)
    }

    @discardableResult
    public func equalTo(_ aView: View) -> LayoutConstraint {
        view?.widthAnchor.constraint(equalTo: aView.widthAnchor).isActive = true
        view?.heightAnchor.constraint(equalTo: aView.heightAnchor).isActive = true
        return constraint
    }

    @discardableResult
    public func equalTo(_ constant: CGFloat) -> LayoutConstraint {
        view?.widthAnchor.constraint(equalToConstant: constant).isActive = true
        view?.heightAnchor.constraint(equalToConstant: constant).isActive = true
        return constraint
    }

    @discardableResult
    public func equalTo(_ constant: CGSize) -> CLYRLayoutConstraint {
        view?.widthAnchor.constraint(equalToConstant: constant.width).isActive = true
        view?.heightAnchor.constraint(equalToConstant: constant.height).isActive = true
        return constraint
    }
}

equalTo(_ constant: CGFloat)メソッドはwidthとheightに同じ値を設定します。つまり正方形ですね。equalTo(_ constant: CGSize)メソッドのようにCGSizeも取れるようにしておくと便利です。

public class LayoutConstraint {
    ...

    public var size: LayoutConstraintSize {
        LayoutConstraintSize(view, self)
    }

    ...
}

LayoutConstraintクラスにsizeプロパティを追加すれば、nextButton.layoutConstraint.size.equalTo(32)と書けるようになります。今回はequalToしか実装していませんが、同様にlessThanOrEqualToやgreaterThanOrEqualToなんかも実装できますね。

まとめ

画面のレイアウトをコーディングする方法としてNSLayoutAnchorのラッパークラスを作りました。SnapKitなどのよくできたライブラリを利用するのが手っ取り早いですが、自前で作っちゃうのもありかもしれません。
そして、プログラムでレイアウトを作る手法は、SwiftUIの時代へと向かっていくわけです。

この記事が気に入ったらサポートをしてみませんか?