iPhone
Xcode
Swift

Playground で(擬似)iPhone X の画面を作ってみる!

この記事は Swift 愛好会 Advent Calendar 2017 7 日目の記事です。

たまたま 7 日目が空いてたので登録しました。ちなみに本日(20 日)の記事もすでに登録してあるのになんで投稿しないの??なんて野暮な質問をしてはいけません(おい

というわけで、Swift がせっかく Playground が使えて、しかも Playground で画面の構成まで確認できて、Playground でも iPhond X の擬似画面を作って遊んでみたいと思ったことありません?

というわけで、作ってみた!

スクリーンショット 2017-12-20 0.34.52.png

元々は自作の Auto Layout を使わずにレイアウトをしてくれるエンジン NotAutoLayout の Playground 用に作ってます。せっかくなので作り方を公開したいと思います。(ちなみに縦画面のみです;今のところ横画面はまだ作ってません。時間あったら作るかもしれません)

まず画面を作るにあたって、一番面倒なのは上のノッチの部分ですね。というわけでまずノッチを作ります。寸法はここを参考にしています。

private final class Notch: UIView {

    override init(frame: CGRect) {
        super.init(frame: frame)
        self.initialize()
    }

    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
        self.initialize()
    }

    convenience init() {
        self.init(frame: .zero)
    }

    override func draw(_ rect: CGRect) {
        super.draw(rect)
        self.drawNotch(topRadius: 6, bottomRadius: 20)
    }

    // `sizeThatFits` メソッドをオーバーライドしてノッチのサイズを返しておくと後で `sizeToFit()` を呼び出すだけで正しいサイズになるので便利です。
    override func sizeThatFits(_ size: CGSize) -> CGSize {
        return CGSize(width: 215, height: 30)
    }

    private func initialize() {

        self.backgroundColor = .clear

    }

    // ノッチをパスで描画
    private func drawNotch(topRadius: CGFloat, bottomRadius: CGFloat) {

        let leftTopArcCenter = CGPoint(x: 0, y: topRadius)
        let rightTopArcCenter = CGPoint(x: self.bounds.width, y: leftTopArcCenter.y)
        let leftBottomArcCenter = CGPoint(x: topRadius + bottomRadius, y: self.bounds.height - bottomRadius)
        let rightBottomArcCenter = CGPoint(x: self.bounds.width - leftBottomArcCenter.x, y: leftBottomArcCenter.y)

        let leftBottomArcLeftPoint = CGPoint(x: topRadius, y: leftBottomArcCenter.y)
        let rightBottomArcBottomPoint = CGPoint(x: self.bounds.width - topRadius - bottomRadius, y: self.bounds.height)
        let rightTopArcLeftPoint = CGPoint(x: self.bounds.width - topRadius, y: topRadius)

        let path = UIBezierPath()
        path.addArc(withCenter: leftTopArcCenter, radius: topRadius, startAngle: .pi * 0.5, endAngle: 0, clockwise: true)
        path.addLine(to: leftBottomArcLeftPoint)
        path.addArc(withCenter: leftBottomArcCenter, radius: bottomRadius, startAngle: .pi, endAngle: .pi * 0.5, clockwise: false)
        path.addLine(to: rightBottomArcBottomPoint)
        path.addArc(withCenter: rightBottomArcCenter, radius: bottomRadius, startAngle: .pi * 0.5, endAngle: 0, clockwise: false)
        path.addLine(to: rightTopArcLeftPoint)
        path.addArc(withCenter: rightTopArcCenter, radius: topRadius, startAngle: .pi, endAngle: .pi * 1.5, clockwise: true)
        path.addLine(to: .zero)

        let fillColor = UIColor.black
        fillColor.setFill()

        path.close()
        path.fill()

    }

}

ノッチは別に外から見れるようにする必要はないのでとりあえず private で定義します。そして一番肝心な部分は drawNotch(topRadius: CGFloat, bottomRadius: CGFloat) ですね。ここでノッチのパスを書いてます。 draw(_ rect: CGRect) をオーバーライドしてこの中に drawNotch すればいいです。ちなみに drawNotch の中身の具体的なパスの書き方について、addLine はわかるんですが、addArcstartAngleendAngle て具体的に一体どういうとりかたなのか結局イマイチわからなかったです。ここのソースコードは色々試行錯誤してできたものです。詳しい人教えてくださると大変喜びます。まあ肝は要するに UIBezierPath を使ってパスを書くんですね。@marty-suzuki さんの記事がかなり参考になるかもしれません

ノッチができたらあとは簡単です。スクリーン用のビューを作って、コーナーを角丸にして、そしてノッチを追加すればいいです。ここで注意したいのはビューに新しいビューを追加した時、ノッチは常に一番上にないとダメなので、ここを工夫する必要があります。例えばビューを追加するための専門の contentsView を作るとか、ビューを追加したらもう一回ノッチを一番上に乗っかるとかしないといけないのですが、まあどのみち addSubview メソッドをオーバーライドする必要があります。ここは後者の方法ビューを追加した後にノッチの上に乗っけるようにします:

public final class IPhoneXScreen: UIView {

    private let notch = Notch()

    public override init(frame: CGRect) {
        super.init(frame: frame)
        self.initialize()
    }

    public required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
        self.initialize()
    }

    public convenience init() {
        self.init(frame: .zero)
    }

}

extension IPhoneXScreen {

    public override func sizeThatFits(_ size: CGSize) -> CGSize {
        return CGSize(width: 375, height: 812)
    }

    // NotAutoLayout でレイアウトを決めます
    public override func layoutSubviews() {
        super.layoutSubviews()

        self.nal.layout(self.notch) { $0
            .pinTopCenter(to: self, s: .topCenter)
            .fitSize()
        }
    }

    public override func addSubview(_ view: UIView) {
        super.addSubview(view)
        assertNotch(with: view)
        super.addSubview(self.notch)
    }

    public override func insertSubview(_ view: UIView, aboveSubview siblingSubview: UIView) {
        super.insertSubview(view, aboveSubview: siblingSubview)
        assertNotch(with: view)
        super.addSubview(self.notch)
    }

    public override func insertSubview(_ view: UIView, belowSubview siblingSubview: UIView) {
        super.insertSubview(view, belowSubview: siblingSubview)
        assertNotch(with: view)
        super.addSubview(self.notch)
    }

    public override func insertSubview(_ view: UIView, at index: Int) {
        super.insertSubview(view, at: index)
        assertNotch(with: view)
        super.addSubview(self.notch)
    }

}

extension IPhoneXScreen {

    // そんなことはないはずだが一応アサートサブビュー追加するとき追加しているのはノッチではないことを確認します
    private func assertNotch(with view: UIView) {
        assert((view == self.notch) == false)
    }

    private func setupVisual() {

        self.backgroundColor = .white
        self.contentScaleFactor = 3

        self.layer.cornerRadius = 40
        self.clipsToBounds = true

    }

    private func setupNotch() {

        self.notch.sizeToFit()
        // ノッチを追加するときは、通常のビューの処理と違って自分を再度追加する必要がないので `super.addSubview` で対応します。
        super.addSubview(self.notch)

    }

    private func initialize() {

        self.setupVisual()
        self.setupNotch()

    }

}

これで Playground の Source に IPhoneXScreen.swift ファイルを作って、上の画面とノッチのソースコードを中にコピペすれば Playground の本体で let view = IPhoneXScreen(); view.sizeToFit() で iPhone X の擬似画面が作れました。

レイアウトの部分は NotAutoLayout で作ってます。見ての通り、layoutSubviews() をオーバーライドして、ノッチを真ん中の上に固定(pinTopCenterTo(_:,s:)してサイズを自動でフィット(fitSize())させています。この fitSize の動きはまさに sizeToFit とほぼ同じです。そう、NotAutoLayout を使えば、Auto Layout なんか使わなくても、こんなに手軽にレイアウトを決められるのです(直球宣伝)。まあもし NotAutoLayout 使わなければ、ここのソースコードはこのようなものに置き換えれば大丈夫でしょう(Auto Layout 使うバージョンは知りません…Auto Layout 絶対殺すマンですから)

    public override func layoutSubviews() {
        super.layoutSubviews()

        self.notch.sizeToFit()
        self.notch.frame.origin.y = 0
        self.notch.center.x = self.bounds.width / 2
    }

さて、ここまで作ったらビジュアル面では iPhone X に見えるようになったが、まだ一つ肝心なものが足りない:そう、iPhone X 対応で一番の肝である Safe Area です。これがシミュレーションできなければ Playground で iPhone X の画面作っても意味がないのです。というわけでこれからは UIViewController の出番です。

UIViewController には additionalSafeAreaInsets というプロパティーがあります。このプロパティーのおかげで、view が Safe Area を参照できるようになるのです。というわけで作ります。

public final class IPhoneXScreenController: UIViewController {

    private lazy var iPhoneXScreen: IPhoneXScreen = {
        let screen = IPhoneXScreen()
        screen.sizeToFit()
        return screen
    }()

    public override init(nibName nibNameOrNil: String?, bundle nibBundleOrNil: Bundle?) {
        super.init(nibName: nibNameOrNil, bundle: nibBundleOrNil)
        self.initialize()
    }

    public required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
        self.initialize()
    }

    public convenience init() {
        self.init(nibName: nil, bundle: nil)
    }

}

extension IPhoneXScreenController {

    // 自分のビューを IPhoneXScreen に
    public override func loadView() {
        self.view = self.iPhoneXScreen
    }

}

extension IPhoneXScreenController {

    // `additionalSafeAreaInsets` で iPhone X の Safe Area を再現します
    private func initialize() {
        self.additionalSafeAreaInsets = .init(top: 44, left: 0, bottom: 34, right: 0)
    }

}

ここまで作れば、もう Playground で iPhone X の擬似画面を使って色々レイアウトを試すことができるようになりました。例えば NotAutoLayout を使って試しにセーフエリアを全部真っ赤にしてみます:

let controller = IPhoneXScreenController()

PlaygroundPage.current.liveView = controller.view

let testView = UIView()
testView.backgroundColor = .red
controller.view.addSubview(testView)

controller.view.nal.layout(testView) { $0
    .setTopLeft(by: { $0.safeOrigin })
    .setSize(by: { $0.safeSize })
}

スクリーンショット 2017-12-20 0.57.03.png

もちろん、Auto Layout 愛用の方は、Auto Layout 制約でも使えるので大丈夫です(SnapKit とか使わないとクッソ面倒いですが):

let controller = IPhoneXScreenController()

PlaygroundPage.current.liveView = controller.view

let testView = UIView()
testView.backgroundColor = .red
controller.view.addSubview(testView)

testView.translatesAutoresizingMaskIntoConstraints = false

testView.topAnchor.constraint(equalTo: controller.view.safeAreaLayoutGuide.topAnchor).isActive = true
testView.bottomAnchor.constraint(equalTo: controller.view.safeAreaLayoutGuide.bottomAnchor).isActive = true
testView.leadingAnchor.constraint(equalTo: controller.view.safeAreaLayoutGuide.leadingAnchor).isActive = true
testView.trailingAnchor.constraint(equalTo: controller.view.safeAreaLayoutGuide.trailingAnchor).isActive = true

スクリーンショット 2017-12-20 0.55.10.png

というわけで、皆さんもぜひ Playground で iPhone X の画面作って色々遊んでみましょー、これで iPhone X の画面対応に少しでも楽になれたらいいなと思います(トオイメ

あ、一応最後に宣伝します:冬コミに Auto Layout 殺す本として、この記事でも登場した自作ライブラリー NotAutoLayout の解説本を出します。表紙入れて全 48 ページのボリュームで現金価格 1000 円で頒布します;また去年みたいに Alipay とかのキャッシュレス決済も可能にしたいと思いますが今のところ対応するプラットフォームと価格はまだ未定です(現金価格よりは安くなる予定です)。100 部刷ったので多分間違いなく余るはずだから興味ある方はぜひ先に嫁の確保をしてから遊びに来て下さい、1 日目(29 日)の東キ-11b です。


追記:NotAutoLayout 2.2.1 公開しました。このバージョンでは Playground サンプルがこの記事で書いた iPhone X の画面になります(というよりこの Playground サンプルを作るためにこの記事を書いたのです)
スクリーンショット 2017-12-21 2.23.46.png