
はじめに
こんにちは、フロントエンド部WEARiOSブロックの西山です。
iOS 13から登場したCompositional Layoutsを使うことで、App Storeのような複雑なUIが簡単に実現できるようになりました。
登場前は、UICollectionView in UICollectionViewまたは、UIStackView + UIScrollView in UICollectionViewで頑張って実現していたところをUICollectionView1つで実現できます。
一方で、登場前の方法では簡単に出来ていたカスタマイズをCompositional Layoutsで実現しようとすると難しくなるケースが存在しました。その1つに、横スクロールするセル全体にドロップシャドウを付ける方法が挙げられます。
WEARには次のようなUIが存在します。

このキャプチャ画像では少し分かりづらいかもしれませんが、セル全体にドロップシャドウが付いています。この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のセルをラップするクラスは公開されていませんでした。

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 } }
背景色darkGrayのViewに丸の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を引くことで繋ぎ目を可能な限り消します。ShadowViewのdrawShadowに手を加えます。
// 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を利用することでより良くなったのではないでしょうか。
サンプルコードはわかりやすいようにシャドウを濃くしていましたが、実際はもう少し薄いので馴染んで見えます。
さいごに
Compositional Layoutsでセル全体にドロップシャドウをつける方法を紹介しました。同じ様な悩みを抱えている方の参考になれば幸いです。以前は割と簡単だった実装もCompositional Layoutsでは難しいケースが他にもあるので、今後のアップデートで更にカスタマイズしやすくなることを期待しています。
WEARではiOSエンジニアを募集しています。ご興味のある方は下記リンクからぜひご応募ください。