[iOS]枠線の上にViewを乗せようとしたらviewとlayerの重なり順に苦しんだ話

Viewに枠線をつけて、その上にViewを乗せるコードを書いていたらUIViewとCALayerの重なり順に手間取ったのでメモ的に記事にします。

souce code
https://gist.github.com/SatoTakeshiX/1b9050a7ea517785e3b45ace0a38a5ac

実行環境

  • Xcode 13
  • iOS 15.0
  • Swift 5.5

(とはいえ、内容はiOS 15でなくても動きます)

枠線の上にViewを表示したい

UIViewに枠線をつけて、その上にバッチのようにUIImageViewを乗せようとしました。
ちょうど次の図のようなレイアウトです。

最初の方針として「UIViewlayerプロパティで枠線をつけて、その上にUIImageViewを表示するAuto Layoutを設定すればよいだろう」と思っていました。

そこでこんなコードを実装しました。
SignboardというViewに緑の枠線を表示するboardViewとその上に王冠のImage ViewであるcrownImageViewを乗せるコードです。

class Signboard: UIView {
    init() {
        super.init(frame: .zero)
        setup()
    }
    override init(frame: CGRect) {
        super.init(frame: frame)
        setup()

    }
    required init?(coder: NSCoder) {
        super.init(coder: coder)
        setup()
    }

    private func setup() {
        addSubview(boardView)
        boardView.topAnchor.constraint(equalTo: topAnchor).isActive = true
        boardView.leadingAnchor.constraint(equalTo: leadingAnchor).isActive = true
        boardView.trailingAnchor.constraint(equalTo: trailingAnchor).isActive = true
        boardView.bottomAnchor.constraint(equalTo: bottomAnchor).isActive = true

        boardView.addSubview(crownImageView)
        crownImageView.topAnchor.constraint(equalTo: boardView.topAnchor, constant: -20).isActive = true
        crownImageView.centerXAnchor.constraint(equalTo: boardView.centerXAnchor).isActive = true
    }

   // 緑の枠線
    private lazy var boardView: UIView = {
        let view = UIView()
        view.translatesAutoresizingMaskIntoConstraints = false
        view.layer.borderWidth = 2
        view.layer.borderColor = UIColor.systemGreen.cgColor
        view.layer.cornerRadius = 4
        return view
    }()

    // 王冠のImage View
    private lazy var crownImageView: UIImageView = {
        let imageView = UIImageView(image: .init(systemName: "crown"))
        imageView.translatesAutoresizingMaskIntoConstraints = false
        imageView.contentMode = .scaleAspectFit
        imageView.widthAnchor.constraint(equalToConstant: 40).isActive = true
        imageView.heightAnchor.constraint(equalToConstant: 40).isActive = true
        imageView.backgroundColor = .systemRed
        return imageView
    }()

これでいけるかと思ったら実際の表示はこちら。

枠線の緑のほうが王冠のImage Viewより上に表示されてしまいます。

どうやらview.layerで枠線を作ると一番最後のレイヤーとして表示されてしまうようです。

この記事ではUIViewもCALayerも追加した順に表示されるそうです。
view.layer自体は最後に追加することになるんでしょうか?🤔
UIViewとCALayerの階層構造 - Qiita

新しくCALayerをつくりaddSublayerする

view.layerは使わず、新しくCALayerを作成してaddSubLayerするようにします。

private func setup() {
    addSubview(boardView)
    boardView.layer.addSublayer(borderLayer)
}

private lazy var boardView: UIView = {
    let view = BoardView()
    view.translatesAutoresizingMaskIntoConstraints = false
    return view
}()

private lazy var borderLayer: CALayer = {
    let layer = CALayer()
    layer.borderWidth = 2
    layer.borderColor = UIColor.systemGreen.cgColor
    layer.cornerRadius = 4
    return layer
}()

ただしborderLayerCALayerframeプロパティはaddSublayerした後もViewに追従するわけではありません。開発者が設定する必要があります。

今回はboardViewboundsと同じ値を入れたいのでboardViewのサブクラスを作ることにしました。
BoardViewです。
UIViewのlayoutSubviewsをオーバーライドしてboundsの情報をクロージャーで外に通知します。

final class BoardView: UIView {
    var didLayoutSubView: ((CGRect) -> Void)?
    init() {
        super.init(frame: .zero)
    }

    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    // サブビューのレイアウトが終わったら呼ばれる
    override func layoutSubviews() {
        super.layoutSubviews()
        // クロージャーで外に通知
        didLayoutSubView?(bounds)
    }
}

Signboardに戻り、boardViewの型をUIViewからBoardViewに変えます。
boardViewlayoutSubViewが終わったらframeborderLayerに更新します。

// Signboard
private func setup() {
    addSubview(boardView)
    boardView.layer.addSublayer(borderLayer)
    boardView.didLayoutSubView = { [weak self] bounds in
       // layerのframe情報をboardViewのもので更新
        self?.borderLayer.frame = bounds
    }
}

// 継承の型をUIViewからBoardViewに変える
private lazy var boardView: BoardView = {
    let view = BoardView()
    view.translatesAutoresizingMaskIntoConstraints = false
    return view
}()

このように書き換えることで無事枠線よりも上に王冠のImage Viewが表示されるようになります。

まとめ

UIViewlayerプロパティでレイアウトすると、addSubViewした他のViewとの表示順がおかしくなります。
別途CALayer作ってaddSublayerしましょう。
CALayerの矩形情報は自分で設定必要なのでUIViewのlayoutSubviewsで矩形情報を取得しましょう。

参考URL

宣伝

インプレスR&D社より、「1人でアプリを作る人を支えるSwiftUI開発レシピ」発売中です。
「SwiftUIでアプリを作る!」をコンセプトにSwiftUI自体の解説とそれを組み合わせた豊富なサンプルアプリでどんな風にアプリ実装すればいいかが理解できる本となっています。
iOS 14対応、Widgetの作成も一章まるまるハンズオンで解説しています。
SwiftUIを学びたい方、ぜひこちらのリンクをチェックしてください!




https://nextpublishing.jp/book/12491.html

また、Visionレームワークの入門書も発売中。
iOSで画像分析に興味のある方は是非チェック!

SwiftUIで学ぶVisionフレームワーク入門