UICollectionViewのCompositional Layoutsでセル全体にドロップシャドウをつける方法

OGP

はじめに

こんにちは、フロントエンド部WEARiOSブロックの西山です。

iOS 13から登場したCompositional Layoutsを使うことで、App Storeのような複雑なUIが簡単に実現できるようになりました。

登場前は、UICollectionView in UICollectionViewまたは、UIStackView + UIScrollView in UICollectionViewで頑張って実現していたところをUICollectionView1つで実現できます。

一方で、登場前の方法では簡単に出来ていたカスタマイズをCompositional Layoutsで実現しようとすると難しくなるケースが存在しました。その1つに、横スクロールするセル全体にドロップシャドウを付ける方法が挙げられます。

WEARには次のようなUIが存在します。

WEARのシャドウが付いている画面

このキャプチャ画像では少し分かりづらいかもしれませんが、セル全体にドロップシャドウが付いています。このUIをCompositional Layoutsで実現するのが一筋縄ではいかなかったので、WEARでの解決方法を紹介します。

環境

  • Xcode 13.4.1
  • Swift 5.6.1

一筋縄ではいかなかった理由

1. セルを覆うクラスが公開されていない

Compositional Layouts登場前のWEARでは、UIStackView + UIScrollView in UICollectionViewで実現していました。UIStackViewをラップするような形でシャドウ用のViewを用意する方法を取っていたので比較的簡単に実現できていました。

Compositional Layoutsでも似たような方法を取れれば簡単に実現できますが、残念ながらCompositional Layoutsのセルをラップするクラスは公開されていませんでした。

_UICollectionViewOrthogonalScrollerEmbeddedScrollViewは公開されていない

2. セルにシャドウを付けると繋ぎ目からはみ出る

セルのラップクラスにシャドウを付けることは適わなそうなので、セル1つ1つにシャドウを付ける方法を取りました。しかし、この方法ではセルとセルの繋ぎ目から前後どちらかのシャドウがはみ出してしまいうまくいきません。

前後のシャドウがはみ出す

解決方法

mask layerを利用する

セルにシャドウを付ける方法を取りつつmask layerを利用し、はみ出す部分を隠します。mask layerの簡単なおさらいですが、Viewの切り抜きや穴を開ける方法で語られることが多いと思います。

class ViewController: UIViewController {

    override func viewDidLoad() {
        super.viewDidLoad()
        view.backgroundColor = .darkGray
        
        // 丸のlayerをセンターに置く
        let maskLayer = CAShapeLayer()
        maskLayer.frame = view.bounds
        
        let width: CGFloat = 200
        let height: CGFloat = 200
        let point = CGPoint(x: view.center.x - width / 2, y: view.center.y - height / 2)
        let rect = CGRect(origin: point, size: .init(width: width, height: height))
        let path = UIBezierPath(roundedRect: rect, cornerRadius: width / 2)
        maskLayer.path = path.cgPath
        
        view.layer.mask = maskLayer
    }
}

背景色darkGrayViewに丸のmask layerをセンターに置いたサンプルコードです。上記コードを実行すると次のようになります。

グレーの丸が切り抜かれる

要するにmask layerと重なる部分が表示されるようになります。

mask layerを使用するためのPosition

やりたいことは、初めのセルの右側、中間のセルの左右、最後のセルの左側を隠すことです。

シャドウを隠したいところ

そのため、どのポジションにいるのか判断できるように型を用意しています。

enum Position {
    case first
    case middle
    case last(isSingle: Bool)
}

extension Position {
    init(index: Int, itemCount: Int) {
        switch (index, itemCount) {
        case let (index, count) where (index + 1) == count:
            self = .last(isSingle: index == 0)
        case (0, _):
            self = .first
        default:
            self = .middle
        }
    }
}

ポジションに合わせてmask layerを定義します。

let width = bounds.width
let height = bounds.height

let maskSpace: CGFloat = 50 // 適切な値を指定
var maskLayer: CALayer? = CALayer()
maskLayer?.backgroundColor = UIColor.black.cgColor

switch position {
case .first:
    // 上、左、下にシャドウが表示されるようlayerを被せる
    maskLayer?.frame = .init(x: -maskSpace,
                             y: -maskSpace,
                             width: width + maskSpace,
                             height: height + maskSpace * 2)
case .middle:
    // 上、下にシャドウが表示されるようlayerを被せる
    maskLayer?.frame = .init(x: 0,
                             y: -maskSpace,
                             width: width,
                             height: height + maskSpace * 2)
case let .last(isSingle):
    if isSingle {
        // 隠したくないのでlayerを削除
        maskLayer = nil
    } else {
        // 上、下、右にシャドウが表示されるようlayerを被せる
        maskLayer?.frame = .init(x: 0,
                                 y: -maskSpace,
                                 width: width + maskSpace,
                                 height: height + maskSpace * 2)
    }
}

WEARでは再利用できるようShadowViewを用意しています。

class ShadowView: UIView {
    private var position: Position?

    override func draw(_ rect: CGRect) {
        super.draw(rect)
        drawShadow()
    }

    func updateShadow(for position: Position) {
        self.position = position
        setNeedsDisplay()
    }

    private func drawShadow() {
        guard let position = position else {
            return
        }

        let width = bounds.width
        let height = bounds.height

        let maskSpace: CGFloat = 50
        var maskLayer: CALayer? = CALayer()
        maskLayer?.backgroundColor = UIColor.black.cgColor

        switch position {
        case .first:
        // mask layerのコード省略
        ...

        layer.shadowOffset = .zero
        layer.shadowColor = UIColor.black.cgColor
        layer.shadowRadius = 10.0
        layer.shadowOpacity = 0.6

        layer.mask = maskLayer
    }
}

この方法を取ることでセル全体へのシャドウをつけることが出来ました。

セル全体へシャドウ適用

shadowPathで更に調整

セル全体へシャドウを適用した時点では、若干繋ぎ目が離れているところが気になります。

繋ぎ目に隙間がある

shadowPathを利用することで、もう少し繋がっている様に見せられます。Viewよりも少し広めにshadowPathを引くことで繋ぎ目を可能な限り消します。ShadowViewdrawShadowに手を加えます。

// mask layerのコードを省略しています
private func drawShadow() {
    let width = bounds.width
    let height = bounds.height

    ... 

    let shadowSpace: CGFloat = 10 // 適切な値を指定
    let shadowPath: UIBezierPath
    
    switch position {
    case .first:
        shadowPath = .init(rect: .init(x: 0,
                                       y: 0,
                                       width: width + shadowSpace,
                                       height: height))

        ...
    case .middle:
        shadowPath = .init(rect: .init(x: -shadowSpace,
                                       y: 0,
                                       width: width + shadowSpace * 2,
                                       height: height))

        ...
    case let .last(isSingle):
        if isSingle {
            shadowPath = .init(rect: .init(x: 0,
                                           y: 0,
                                           width: width,
                                           height: height))

            ...
        } else {
            shadowPath = .init(rect: .init(x: -shadowSpace,
                                           y: 0,
                                           width: width + shadowSpace,
                                           height: height))

            ...
        }
    }

    layer.shadowPath = shadowPath.cgPath
    ...

shadowPathを利用したセル全体へのシャドウ適用

完璧とまではいきませんが、shadowPathを利用することでより良くなったのではないでしょうか。 サンプルコードはわかりやすいようにシャドウを濃くしていましたが、実際はもう少し薄いので馴染んで見えます。

さいごに

Compositional Layoutsでセル全体にドロップシャドウをつける方法を紹介しました。同じ様な悩みを抱えている方の参考になれば幸いです。以前は割と簡単だった実装もCompositional Layoutsでは難しいケースが他にもあるので、今後のアップデートで更にカスタマイズしやすくなることを期待しています。

WEARではiOSエンジニアを募集しています。ご興味のある方は下記リンクからぜひご応募ください。

hrmos.co

カテゴリー