iOS

[Swift] 뉴모피즘 구현하기 (inner Shadow, HSB 색상 추출)

스츠흐 2024. 10. 2. 00:18

2023년 디자인 트렌드에서 두 가지 키워드가 눈에 들어왔습니다.

첫 번째는 글래스모피즘(glassmorphism)이었고, 두 번째는 뉴모피즘(neumorphism)이었습니다.

 

먼저 글래스모피즘 라이브러리를 만들었고, 이후 UI 패키지 제작에 재미를 붙여서 뉴모피즘(neumorphism) 라이브러리도 제작했습니다.

이 글에서는 뉴모피즘이 무엇인지 소개하고, 제작하면서 신경 썼던 부분이 무엇인지 소개하고자 합니다.

 

(Swift 언어로 작성되었고, UIKit에서 사용할 수 있는 뉴모피즘뷰 라이브러리입니다)

 

https://github.com/Chaehui-Seo/CHNeumorphismView

 

GitHub - Chaehui-Seo/CHNeumorphismView: Assistant for Neumorphism UI in iOS

Assistant for Neumorphism UI in iOS. Contribute to Chaehui-Seo/CHNeumorphismView development by creating an account on GitHub.

github.com

 

 

 

0. 뉴모피즘이란?

뉴모피즘이란 그림자를 통해서 돌출되고 들어간 영역을 표현하는 디자인 방식을 의미합니다.

직접적인 테두리 대신에 그림자를 이용하기 때문에, 부드러운 느낌을 줍니다.

대신, 그렇기 때문에 UI요소의 경계가 명확하지 않다는 단점이 있습니다.

이 지점이 현실적으로 뉴모피즘 디자인이 실제 서비스에 반영되지 않는 이유라고 할 수 있습니다.

 

 

 

1. 설계

뉴모피즘 효과는 크게 두 가지 종류가 있습니다.

(정확한 명칭은 모르겠지만) 저는 오목하게 들어간 효과를 Concave(Inside curve), 볼록하게 나오는 효과를 Convex(Outside curve)로 명명했습니다.

두 가지 효과의 차이는 그림자의 위치에 있습니다.

Concave 효과는 inner shadow를, Convex 효과는 drop shadow를 활용해야 합니다.

두 효과의 계층 구조를 아래와 같이 설계했습니다.

뉴모피즘뷰 설계

 

커스텀 할 수 있는 요소는 아래와 같이 정했습니다.

  1. 효과 종류 (Inside / Outside curve)
  2. 효과의 강도
  3. 모서리 둥글기 값
  4. 그림자 색상 (밝은 그림자, 어두운 그림자)

 

이전에 만든 글래스모피즘뷰와 다른 점이 있다면, UIStoryboard에서의 사용성을 높이기 위해 신경을 썼습니다.

메소드 형식이 아닌 @IBInspectable로 선언을 하여서 UIStoryboard의 인스펙터에서 속성을 변경할 수 있도록 하였습니다.

 

 

2. Inner Shadow

Concave(오목효과)를 구현하기 위해서는 inner shadow가 필요합니다.

그렇지만, Swift에서(특히 UIKit에서) inner shadow를 만드는 것은 의외로 쉽지 않습니다.

기본 제공하는 방법으로는 inner shadow를 만들 수 없고, gradient 레이어를 이용하는 것은 부자연스럽다고 생각했습니다. (특히 2면 이상에 걸쳐진 그림자는 그 경계가 부자연스럽게 표현됩니다.)

또 다른 방법으로는, layer의 fillRule을 evenOdd로 설정해서 중간이 뚫린 뷰를 만들고 그 뷰에 그림자를 넣는 방식이 있습니다.

이 방법은 비교적 단순하게 inner shadow를 구현할 수 있다는 장점이 있지만, cornerRadius를 표현하기 어렵다는 한계가 있습니다.

(path에 cornerRadius를 추가할 수 있는데, 이유는 모르지만 오차가 발생하여서 부자연스럽게 보입니다.)

 

고민 끝에 도형 외부에 UIBezierPath로 cornerRadius까지 반영하여 그림자를 위한 path를 그리는 방식을 선택했습니다.

그림자가 보여야 하는 좌측상단(어두운 그림자)과 우측하단(밝은 그림자) 외부에 path를 그리고, 이 path의 shapeLayer를 그림자뷰에 sublayer로 추가했습니다.

그림자를 제외한 부분들은 보이지 않도록 clipsToBounds 및 masksToBounds 설정을 하였습니다.

 

코드가 복잡해지긴 했지만, cornerRadius까지 완전히 반영된 그림자를 그리기에는 이 방식이 가장 적합하다고 판단했습니다.

// MARK: - 어두운 그림자 세팅
// 어두운 그림자 바탕 view 세팅
let darkShadowView = UIView(frame: self.bounds)
darkShadowView.layer.cornerRadius = self.layer.cornerRadius
darkShadowView.translatesAutoresizingMaskIntoConstraints = false
darkShadowView.clipsToBounds = true
darkShadowView.layer.shadowColor = darkShadowColor != nil
                                    ? darkShadowColor?.cgColor
                                    : self.backgroundColor?.makeDarkerColor().cgColor
darkShadowView.layer.shadowOpacity = Float(0.8 * intensity)
darkShadowView.layer.shadowOffset = CGSize(width: 5, height: 5)
darkShadowView.layer.shadowRadius = 5
darkShadowView.layer.shouldRasterize = true
darkShadowView.layer.masksToBounds = true

// 어두운 그림자를 적용할 path 생성
// (왼쪽과 위쪽 테두리(cornerRadius 반영)를 따르면서 50씩 확장된 path)
let darkPath = UIBezierPath()
darkPath.move(to: CGPoint(x: 0, y: self.bounds.height))
darkPath.addLine(to: CGPoint(x: -50, y: self.bounds.height))
darkPath.addLine(to: CGPoint(x: -50, y: -50))
darkPath.addLine(to: CGPoint(x: self.bounds.width, y: -50))
darkPath.addLine(to: CGPoint(x: self.bounds.width, y: 0))
darkPath.addLine(to: CGPoint(x: self.layer.cornerRadius, y: 0))
darkPath.addArc(withCenter: CGPoint(x: self.layer.cornerRadius, y: self.layer.cornerRadius), radius: self.layer.cornerRadius, startAngle: .pi * 3 / 2, endAngle: .pi, clockwise: false)
darkPath.close()

// 위 path를 가지는 shapeLayer 생성 및 뷰에 레이어 추가
let darkShapeLayer = CAShapeLayer()
darkShapeLayer.path = darkPath.cgPath
darkShapeLayer.fillColor = darkShadowColor != nil
                            ? darkShadowColor?.cgColor
                            : self.backgroundColor?.makeDarkerColor().cgColor
darkShadowView.layer.addSublayer(darkShapeLayer)

// MARK: - 밝은 그림자 세팅
// 밝은 그림자 바탕 view 세팅
let lightShadowView = UIView(frame: self.bounds)
lightShadowView.layer.cornerRadius = self.layer.cornerRadius
lightShadowView.translatesAutoresizingMaskIntoConstraints = false
lightShadowView.clipsToBounds = true
lightShadowView.layer.shadowColor = lightShadowColor != nil
                                    ? lightShadowColor?.cgColor
                                    : UIColor.white.cgColor
lightShadowView.layer.shadowOpacity = Float(0.8 * intensity) // Shadow of concave effect could cover the contents of the view. Therefore, default opacity value should be more transparent than the one in convex effect
lightShadowView.layer.shadowOffset = CGSize(width: -5, height: -5)
lightShadowView.layer.shadowRadius = 5
lightShadowView.layer.shouldRasterize = true
lightShadowView.layer.masksToBounds = true

// 밝은 그림자를 적용할 path 생성
// (오른쪽과 아래쪽 테두리(cornerRadius 반영)를 따르면서 50씩 확장된 path)
let lightPath = UIBezierPath()
lightPath.move(to: CGPoint(x: self.bounds.width, y: 0))
lightPath.addLine(to: CGPoint(x: self.bounds.width + 50, y: 0))
lightPath.addLine(to: CGPoint(x: self.bounds.width + 50, y: self.bounds.height + 50))
lightPath.addLine(to: CGPoint(x: 0, y: self.bounds.height + 50))
lightPath.addLine(to: CGPoint(x: 0, y: self.bounds.height))
lightPath.addLine(to: CGPoint(x: self.bounds.width - self.layer.cornerRadius, y: self.bounds.height))
lightPath.addArc(withCenter: CGPoint(x: self.bounds.width - self.layer.cornerRadius, y: self.bounds.height - self.layer.cornerRadius), radius: self.layer.cornerRadius, startAngle: .pi / 2, endAngle: 0, clockwise: false)
lightPath.close()

// 위 path를 가지는 shapeLayer 생성 및 뷰에 레이어 추가
let lightShapeLayer = CAShapeLayer()
lightShapeLayer.path = lightPath.cgPath
lightShapeLayer.fillColor = self.backgroundColor?.cgColor
lightShadowView.layer.addSublayer(lightShapeLayer)

// MARK: - 실제 뷰 세팅
// 바탕 view들 추가 및 레이아웃 설정
self.addSubview(darkShadowView)
self.addSubview(lightShadowView)
NSLayoutConstraint.activate([
    darkShadowView.leadingAnchor.constraint(equalTo: self.leadingAnchor),
    darkShadowView.trailingAnchor.constraint(equalTo: self.trailingAnchor),
    darkShadowView.bottomAnchor.constraint(equalTo: self.bottomAnchor),
    darkShadowView.topAnchor.constraint(equalTo: self.topAnchor),
    lightShadowView.leadingAnchor.constraint(equalTo: self.leadingAnchor),
    lightShadowView.trailingAnchor.constraint(equalTo: self.trailingAnchor),
    lightShadowView.bottomAnchor.constraint(equalTo: self.bottomAnchor),
    lightShadowView.topAnchor.constraint(equalTo: self.topAnchor),
])

 

 

3. HSB 기반한 색상 추출

어두운 그림자의 색상을 custom하게 추가하지 않는다면, 기본적으로 뷰의 배경색상을 기준으로 어두운 색을 뽑도록 구현했습니다.

 

초기에는 어두운 그림자 색을 Opacity가 적용된 black으로 하려고 하였는데, 생각보다 부자연스럽다고 느꼈습니다.

효과의 완성도를 높이기 위해서 black보다는 현재 색을 기반으로 어두운 색을 추출하고자 하기로 결정했습니다.

그리고 이렇게 색을 추출하기에는 RGB보다 HSB 방식을 이용하는 것이 가장 적절하다고 생각했습니다.

(원래는 HSL 기반으로 구현하였는데, Swift의 UIColor는 RGB와 HSB 생성만 기본 제공했기 때문에 HSL->HSB 변환 과정을 거쳐야 했습니다. 이에 HSB로 색을 추출하도록 수정하였습니다.)

 

우선, 피그마에서 대략적인 색을 뽑아보았습니다.

기준 색과 어두운 그림자일 때 색을 뽑았고, 이때 최대한 기준 색의 느낌을 유지하면서 어둡고 짙은 색으로 추출하였습니다.

그리고 이 두 색이 컬러패널 상에서 어느 쪽으로 이동하는지 확인하였습니다.

 

우측 아래 대각선 방향으로 이동해야 함을 확인했습니다.

즉, 색상(hue)은 유지한 상태로 saturation은 높이고 brightness는 낮춰야 합니다.

 

아래와 같은 기준으로 어두운 그림자 색을 추출하였습니다.

extension UIColor {
    // MAR: - hsb color
    var hsb: (hue: CGFloat, saturation: CGFloat, brightness: CGFloat) {
        var hsb: (hue: CGFloat, saturation: CGFloat, brightness: CGFloat) = (0, 0, 0)
        self.getHue(&(hsb.hue), saturation: &(hsb.saturation), brightness: &(hsb.brightness), alpha: nil)
        return hsb
    }
    
    // MARK: - Make dark shadow color based on the origional color
    func makeDarkerColor(alpha: CGFloat = 1.0) -> UIColor {
        let h = self.hsb.hue
        let s = self.hsb.saturation <= 0.8 ?  self.hsb.saturation + 0.2 : 1.0
        let b = self.hsb.brightness >= 0.2 ?  self.hsb.brightness - 0.2 : 0.0
        return UIColor.init(hue: h, saturation: s, brightness: b, alpha: alpha)
    }
}

 

 

 

4. 마치며

이렇게 해서 아래와 같은 결과물을 만들 수 있었습니다.

(해당 앱은 패키지 내부의 SampleApp 프로젝트에서 빌드할 수 있습니다.)

 

뉴모피즘뷰를 만들 때는, 어떻게 하면 디자인한 부분들을 그대로 구현해 낼 수 있을까를 고민을 많이 했던 것 같습니다.

전술한 바와 같이, 실제 서비스에서 쓰이기에는 한계가 있는 디자인 기법이어서 제가 만든 패키지가 실제로 많이 쓰이진 않을 것 같지만, 여러 고민을 하고 구현해 나가는 과정이 재미있었습니다! :)