この記事は Goodpatch Advent Calendar 2019 の23日目です.

iOS アプリの UI デザインで elevation について意識することは Android の場合と比較すると少ないと思います. ただし,Material Design 自体はプラットフォームを限定せずに適用可能な考え方であり,elevation は Material Components を使うかどうかを別としても UI をデザインするために有用です. ということで UIView で elevation を表現する方法について Material Components も参考にしながら実装してみました.

概要

  • UIView の CALayer に影を付けて elevation っぽい表現をする
  • Elevation の値に応じて影の radius や offset を設定する
  • 影は ambient と key の2種類をつける

という方針で実装しました.

環境

  • Xcode 11.0
  • iOS Simulator 13.0

MDC の影を試す

Material Components for iOS の影がどのような挙動になっているのかデモを作って確認してみます.

MDC elevation 1 MDC elevation 2 MDC elevation 3 MDC elevation 5 MDC elevation 8 MDC elevation 13

UIView に影をつける

Material Components (MDC) を参考にしながら,UIView に影をつけていきます.

Radius を設定する

まずは UIView の layer に最も単純な形で影をつけてみます. とりあえず elevation の高さ分だけ shadowRadius をつけてみます.

class CardView: UIView {
    func setShadowElevation(_ elevation: CGFloat) {
        let cornerRadius: CGFloat = 4
        self.layer.cornerRadius = cornerRadius

        self.layer.shadowColor = UIColor.black.cgColor
        self.layer.shadowRadius = elevation
        self.layer.shadowOffset = .zero
        self.layer.shadowOpacity = 0.33
    }
}

Shadow radius 1 Shadow radius 2 Shadow radius 3 Shadow radius 5 Shadow radius 8 Shadow radius 13

UIView の方はカードの中心(よりやや下?)に点光源があるような影のつき方で,MDC のような強い elevation は感じられません.

Offset を変更する

続いて shadowOffset も設定してみます. とりあえず elevation の高さ分だけ Y 軸方向にずらします.

self.layer.shadowOffset = CGSize(width: 0, height: elevation)

Shadow offset 1 Shadow offset 2 Shadow offset 3 Shadow offset 5 Shadow offset 8 Shadow offset 13

だいぶそれらしくなりました. ぱっと見はどちらも区別がつかないかもしれません. ただし,よく見てみると,なんとなく MDC の方が「カード」らしい存在感があるようにも見えます. 特に elevation が小さいときが顕著です.

そこで elevation = 1 の両者を拡大して並べてみると,MDC の方は edge 付近の影が濃くなっていることが分かります.

Top Bottom
Comparing MDC and UIView 1 Comparing MDC and UIView 2
MDC(左半分)の方が影の領域が広い. MDC(左半分)は edge 付近の影がより濃い.薄くなっていく勾配も線形ではない.

2つの影をつける

Material Design Guideline の Light and shadows を読むと,Material Design では ambient と key の2種類の影が必要なことが分かります.

その実装である MDCShadowLayer.m を読むと,ガイドラインどおり ambient と key の2種類の影が設定されていることが分かります. Ambient は elevation によって offset が変わらない影で,opacity もかなり薄くつけられています. また Radius の値は elevation の一次関数で決まっていますが,ambient と key でそれぞれに係数が調整されています.

Ambient Opacity Key Opacity
0.08 0.26
Elevation Ambient Radius Key Radius Key Offset
0 -.– -.– -.–
1 0.88 0.66 1.19
2 1.77 1.33 2.42
3 2.66 1.99 3.65
5 4.44 3.33 6.11
8 7.11 5.33 9.81
13 11.56 8.66 15.96

というわけで,ambient 用の CALayer を subLayer として追加します.

class CardView: UIView {
    private weak var ambientShadowLayer: CALayer?

    let maxElevation: CGFloat = 16

    func setShadowElevation(_ elevation: CGFloat) {
        let cornerRadius: CGFloat = 4
        self.layer.cornerRadius = cornerRadius

        self.layer.shadowColor = UIColor.black.cgColor
        self.layer.shadowRadius = elevation
        self.layer.shadowOffset = CGSize(width: 0, height: elevation)
        self.layer.shadowOpacity = 0.33

        let ambientShadowLayer = self.ambientShadowLayer ?? { (view: UIView) -> CALayer in
            let superLayer = view.layer

            let layer = CAShapeLayer()
            layer.bounds = superLayer.bounds
            layer.anchorPoint = .zero

            let path = UIBezierPath(roundedRect: superLayer.bounds, cornerRadius: superLayer.cornerRadius)
            layer.path = path.cgPath
            layer.fillColor = view.backgroundColor?.cgColor

            let maskPath = UIBezierPath(rect: superLayer.bounds.insetBy(dx: -maxElevation * 2, dy: -maxElevation * 4))
            maskPath.append(path)

            let mask = CAShapeLayer()
            mask.path = maskPath.cgPath
            mask.bounds = superLayer.bounds
            mask.anchorPoint = .zero
            mask.fillRule = .evenOdd
            mask.fillColor = view.backgroundColor?.cgColor
            layer.mask = mask

            view.layer.addSublayer(layer)
            return layer
        }(self)
        self.ambientShadowLayer = ambientShadowLayer

        ambientShadowLayer.shadowColor = UIColor.black.cgColor
        ambientShadowLayer.shadowRadius = elevation
        ambientShadowLayer.shadowOffset = .zero
        ambientShadowLayer.shadowOpacity = 0.11
    }
}

レイヤーに設定した影が描画されるためにはレイヤーに塗りが必要ですが,レイヤーに塗りがあると元の UIView の描画を邪魔してしまうので mask を作って元の UIView 部分をくり抜いて透明化しています.

Shadow ambient 1 Shadow ambient 2 Shadow ambient 3 Shadow ambient 5 Shadow ambient 8 Shadow ambient 13

かなりそれらしくなりました. あとは opacity や offset の係数を調整して各々のいい感じに調整していけば良さそうです.

One More Thing

Ambient と key の2つの影を設定できるようになったので,それぞれに black 以外の色を指定することもできます. 試しに orange を設定してみると次のようになりました.

Shadow ambient colored 1 Shadow ambient colored 2 Shadow ambient colored 3 Shadow ambient colored 5 Shadow ambient colored 8 Shadow ambient colored 13

アプリの性格やコンテンツの内容に合わせて色付きの影(ただし,もっとさりげない色で)を入れてみるのも良さそうです.

まとめ・感想

UIView で Material Design のような elevation の表現を実装する方法をまとめました.

  • Elevation によって影の radius と offset を設定した
  • Ambient と key の2つの影を設定した
    • Ambient は元の UIView のサブレイヤーとして追加した
    • そのままだと UIView の描画を邪魔するのでマスクをかけた

コスパを重視するなら key シャドーだけでも良さそうですが,ambient が加わるだけでカードの存在感が増したり,表現の幅が広がったりして面白いです.

参考