38
28

More than 3 years have passed since last update.

UIKitでNeumorphismのデザインを構築するライブラリ

Posted at

Neumorphism(ニューモーフィズム)は、UIデザインの次のトレンドになるかもしれないと昨年末あたりから言われ出している表現手法です。本当にそうなるかどうかは分かりませんが、実験としてiOS用のライブラリ EMTNeumorphicView を作ってみました。ここではその実装アプローチについて書きます。

Neumorphismとは

フラットデザインやMaterial Designから、かつて流行したスキューモーフィックデザインに少し回帰したようなデザインといえばわかりやすいと思います。
EMTNeumorphicView screenshot

詳細は下記にあります。

自分もnoteを書きました。

凸型 (convex) のUIを作成する

Neumorphismのデザインを実現するには、要素に角Rを付けたうえで明暗2色のシャドウを落とさなければなりません。

実装方法はいろいろあると思いますが、EMTNeumorphicViewではシャドウ用のサブレイヤーを生成するカスタムCALayerを作りました。ライブラリではそれをlayerClassにしたUIView、UIButton、UITableViewCellを提供します。

EMTNeumorphicView.swift
    public override class var layerClass: AnyClass {
        return EMTNeumorphicLayer.self
    }

シャドウをCALayerで実装しているため、ボタンの状態変更にはCore Animationが効いています。
toggle buttons

カスタムCALayer内では、次のような感じで要素色、明るいシャドウ、暗いシャドウの3つのサブレイヤーを使っています。

EMTNeumorphicLayer.swift
masksToBounds = false

colorLayer = CALayer()
shadowLayer = EMTShadowLayer()
lightLayer = EMTShadowLayer()

insertSublayer(colorLayer!, at: 0)
insertSublayer(lightLayer!, at: 0)
insertSublayer(shadowLayer!, at: 0)

colorLayer?.masksToBounds = true
shadowLayer?.masksToBounds = false
lightLayer?.masksToBounds = false

明るいシャドウと暗いシャドウのレイヤーに、shadowPathを使って影をつけます。それぞれ左上方向と右下方向にオフセットし、Neumorphismのシャドウを表現します。

ShadowLayer.swift
let cornerRadius: CGFloat = 24
let shadowRadius: CGFloat = 8
let offsetWidth: CGFloat = shadowRadius / 2
let cornerRadii: CGSize = CGSize(width: cornerRadius - offsetWidth, 
                                 height: cornerRadius - offsetWidth)

var shadowX: CGFloat = offsetWidth
var shadowY: CGFloat = offsetWidth

// reverse offset direction
if mode == isLightSide {
    shadowX *= -1
    shadowY *= -1
}

// add shadow
let shadowBounds = bounds
let path = UIBezierPath(roundedRect: shadowBounds.insetBy(dx: offsetWidth, dy: offsetWidth),
                        byRoundingCorners: [.topLeft, .topRight, .bottomLeft, .bottomRight],
                        cornerRadii: cornerRadii)
shadowPath = path.cgPath
shadowOffset = CGSize(width: shadowX, height: shadowY)

凹型 (concave) のUIを作成する

凹型はトグルボタンやテーブルなどを表現するのに向いていますが、明暗2色をインナーシャドウで表現しなければならないため、凸型よりもずっと実装が面倒です。

要点は次のような感じです

  • 明暗2色のシャドウを2つのレイヤーで表現するのは凸型と同じ
  • 必要な部分のみ表示されるようにマスクをかけ、明暗が綺麗に合成されるようにする

ポイントとなる部分のみ説明します。まずインナーシャドウを作成します。
EMTNeumorphicViewでは、要素の形状に沿った中空のUIBezierPathを作成し、それをレイヤーのshadowPathとして使うことでインナーシャドウを描画しています。

ShadowLayer.swift
masksToBounds = true
cornerRadius = 24

let gap: CGFloat = 1
let cornerRadii = CGSize(width: cornerRadius + gap, height: cornerRadius + gap)
let innerRadius = cornerRadius - gap
let cornerRadiiInner = CGSize(width: innerRadius, height: innerRadius)

let outerPath = UIBezierPath(roundedRect: bounds.insetBy(dx: -gap, dy: -gap),
                             byRoundingCorners: corners,
                             cornerRadii: cornerRadii)
let innerPath = UIBezierPath(roundedRect: bounds.insetBy(dx: gap, dy: gap),
                             byRoundingCorners: corners,
                             cornerRadii: cornerRadiiInner).reversing()

outerPath.append(innerPath)
shadowPath = path.cgPath

問題は明暗2つのシャドウの合成です。凸型の場合はシャドウの重なる部分は要素の後ろ側に隠れてしまうので合成する必要はありませんでした。
凹型の場合、明暗が重なって入れ替わる部分(要素の右上と左下の角R部分)がうまく合成されるように個別にマスクをかけます。
マスク領域を説明するために色をつけると次のようになります。マゼンタがダークシャドウ用、シアンがライトシャドウ用です。
mask area

マスクを生成するためのカスタムレイヤーを作り、drawメソッド内でマスク領域を描画します。
以下はライトシャドウの右上の角R部分をマスクするためのコードです(黒→透明色のグラデーションを右下→左上へ描画)。
ダークシャドウの場合は同じ領域をグラデーションの方向を逆にして描画します。

GradientMaskLayer.swift

override func draw(in ctx: CGContext) {
    // top-right corner
    let cornerRect = CGRect(x: frame.size.width - cornerRadius,
                            y: 0, 
                            width: cornerRadius, 
                            height: cornerRadius)

    let bottomRight = CGPoint(x: cornerRect.maxX, y: cornerRect.maxY)

    guard let gradient = CGGradient(colorsSpace: CGColorSpaceCreateDeviceRGB(),
                                    colors: [UIColor.black.cgColor, UIColor.clear.cgColor] as CFArray,
                                    locations: [0, 1]) else { return }
    ctx.saveGState()
    ctx.addRect(cornerRect)
    ctx.clip()
    ctx.drawLinearGradient(gradient, start: bottomRight, end: cornerRect.origin, options: [])
    ctx.restoreGState()

まとめ

もっといい方法がある気もしますがとりあえずこんな感じです。

38
28
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
38
28