はじめに
こんにちは、フロントエンド部WEARiOSブロックの西山です。
iOS 13から登場したCompositional Layoutsを使うことで、App Storeのような複雑なUIが簡単に実現できるようになりました。
登場前は、UICollectionView in UICollectionView
または、UIStackView + UIScrollView in UICollectionView
で頑張って実現していたところをUICollectionView
1つで実現できます。
一方で、登場前の方法では簡単に出来ていたカスタマイズを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エンジニアを募集しています。ご興味のある方は下記リンクからぜひご応募ください。