Neumorphism(ニューモーフィズム)は、UIデザインの次のトレンドになるかもしれないと昨年末あたりから言われ出している表現手法です。本当にそうなるかどうかは分かりませんが、実験としてiOS用のライブラリ EMTNeumorphicView を作ってみました。ここではその実装アプローチについて書きます。
#Neumorphismとは
フラットデザインやMaterial Designから、かつて流行したスキューモーフィックデザインに少し回帰したようなデザインといえばわかりやすいと思います。
詳細は下記にあります。
自分もnoteを書きました。
#凸型 (convex) のUIを作成する
Neumorphismのデザインを実現するには、要素に角Rを付けたうえで明暗2色のシャドウを落とさなければなりません。
実装方法はいろいろあると思いますが、EMTNeumorphicViewではシャドウ用のサブレイヤーを生成するカスタムCALayerを作りました。ライブラリではそれをlayerClassにしたUIView、UIButton、UITableViewCellを提供します。
public override class var layerClass: AnyClass {
return EMTNeumorphicLayer.self
}
シャドウをCALayerで実装しているため、ボタンの状態変更にはCore Animationが効いています。
カスタムCALayer内では、次のような感じで要素色、明るいシャドウ、暗いシャドウの3つのサブレイヤーを使っています。
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のシャドウを表現します。
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として使うことでインナーシャドウを描画しています。
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部分)がうまく合成されるように個別にマスクをかけます。
マスク領域を説明するために色をつけると次のようになります。マゼンタがダークシャドウ用、シアンがライトシャドウ用です。
マスクを生成するためのカスタムレイヤーを作り、drawメソッド内でマスク領域を描画します。
以下はライトシャドウの右上の角R部分をマスクするためのコードです(黒→透明色のグラデーションを右下→左上へ描画)。
ダークシャドウの場合は同じ領域をグラデーションの方向を逆にして描画します。
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()
#まとめ
もっといい方法がある気もしますがとりあえずこんな感じです。