iOS

[Swift] 갈대 유리 효과 구현하기 (효과 설계 과정, CIFilter)

스츠흐 2024. 10. 13. 22:51

2024년 디자인 트렌드를 둘러봤을 때, 갈대 유리 효과(Reeded glass effect)가 눈에 띄었습니다.

이전에 만들었던 글래스모피즘과 '유리(glass)'라는 키워드를 동일하게 가지고 있지만,

두 효과가 주는 유리의 질감 및 느낌이 꽤 다르게 느껴졌고, 만들면 재밌겠다는 생각이 들었습니다.

 

(마찬가지로 Swift 언어로 작성되었고, UIKit에서 사용할 수 있는 라이브러리입니다.)

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

 

GitHub - Chaehui-Seo/ReededGlassView: Assistant for Reeded glass effect UI in iOS

Assistant for Reeded glass effect UI in iOS. Contribute to Chaehui-Seo/ReededGlassView development by creating an account on GitHub.

github.com

 

 

0. 갈대 유리 효과란?

갈대 유리 효과는 이미지 위에 유리가 얹어진 듯한 디자인을 의미합니다.

이때 얹어지는 유리는 세로의 촘촘한 패턴이 있는 유리(모루 유리와 유사한 형태)입니다.

아래 사진 좌측 속 유리의 속성을 우측처럼 디자인에 적용한 것이 갈대 유리 효과에 해당합니다.

chatGPT로 생성한 유리 사진 예시(좌), 갈대 유리 효과 적용 화면(우)

 

 

1. 설계

갈대 유리 효과를 적용하기 위해서는 크게 2개의 레이어가 필요합니다.

배경 이미지를 잘라서 압축시킴으로 개별 유리 효과를 구현한 뷰가 있어야 하고, 그 위에 유리의 굴곡을 표현할 그라디언트 뷰가 필요합니다.

이미지가 보이게 하기 위해서 ReededGlassView를 반투명하게 표시. 실제론 뒷 배경과 일치하는 색상(위 경우 어두운 회색)을 적용함.

 

 

이전에 제작했던 다른 뷰들(글래스모피즘뷰, 뉴모피즘뷰)과 다르게, 배경에 있는 원본 이미지 뷰와 갈대 유리 효과가 적용된 뷰가 독립적으로 존재를 해야 했습니다.

또, 동시에 효과 뷰가 원본 이미지 뷰의 정보는 물론이고, 원본 이미지와 자신의 위치적 관계 등을 알아야 했습니다.

이를 위해 효과 뷰와 원본 이미지 뷰가 동일 계층하에 존재하도록 가이드를 하였고, 효과를 적용할 때 원본 이미지를 파라미터로 건네주도록 설계했습니다.

 

커스터마이징을 할 수 있는 요소는 한 가지, 유리의 너비만을 제공하기로 하였습니다.

narrow, default, wide 중에 갈대 유리의 너비를 선택할 수 있다.

 

 

2. 개별 유리 효과 구현

갈대 유리 효과의 느낌을 살리기 위해서, 해당 디자인 튜토리얼과 적용된 사례들을 많이 찾아봤습니다.

그 결과 개별 유리에 비치는 이미지는 원본 이미지의 일부가 수평/수직으로 모두 압축된 형태인 것이 좋겠다고 판단했습니다.

 

이를 위해서, 현재 유리 사이즈에서 수평으로 2배에 해당하는 이미지를 가져와서 압축하기로 하였습니다.

유리의 너비를 width라고 칭했을 때, 좌·우측으로 각각 {width / 2}씩 이동하여서 총 {width * 2}가 되는 이미지를 추출하였습니다.

이렇게 설계하였을 때, 좌측 혹은 우측으로 {width / 2}을 더 확장하지 못하는 케이스가 생깁니다.

원본 이미지와 효과 뷰 사이의 간격이 {width / 2}보다 좁을 때, 뷰의 시작과 끝 유리에서 이런 현상이 발생합니다.

이 경우에는, 원본 이미지의 시작점과 끝점을 기준으로 {width * 2}에 해당하는 이미지를 추출하도록 하였습니다.

압축할 추출 이미지의 너비를 줄이는 것은 전체적인 효과의 일관성을 해칠 수 있다고 판단하여서, "추출 이미지의 너비 = {width * 2}" 라는 기준은 고정하기로 정한 것이었습니다.

 

수직으로의 압축은 수평만큼 드라마틱할 필요가 없다고 판단하여서, 효과 뷰의 높이보다 5만큼만 확장하기로 하였습니다.

(추가적으로 가우시안 블러 효과를 적용한 후, 테두리가 블러처리 되며 전반적인 사이즈가 작아졌습니다. 이를 해결하기 위해서 전체적으로 확장해 주었습니다.)

let targetImageView = (원본 이미지 뷰)

var shrinkImageXPosition = 0 // 'self'에서의 상대적 x값을 저장할 예정
let shrinkImageWidth = width * 2

if self.frame.minX + xPosition - (width / 2) >= targetImageView.frame.minX
    && self.frame.minX + xPosition + (width) + (width / 2) <= targetImageView.frame.maxX {
    // 케이스 1) 기본 케이스. 좌우측에 (width / 2)씩, 기본에 width의 너비를 가짐
    shrinkImageXPosition = xPosition - (width / 2)
} else {
    if self.frame.minX + xPosition - (width / 2) < targetImageView.frame.minX {
        // 케이스 2) 좌측으로 (width / 2)를 갈 수 없는 경우
        shrinkImageXPosition = targetImageView.frame.minX - self.frame.minX // 원본 이미지의 시작점
    } else {
        // 케이스 3) 우측으로 (width / 2)를 갈 수 없는 경우
        let remainedWidth = shrinkImageWidth - (targetImageView.frame.maxX - (self.frame.minX + xPosition))
        shrinkImageXPosition = xPosition - remainedWidth // 원본 이미지의 끝점에서 (width * 2) 떨어진 점
    }
}

// 위에서 계산한 포지션과 사이즈의 이미지 생성
let view = UIView(frame: CGRect(x: shrinkImageXPosition, y: 0, width: shrinkImageWidth, height: self.frame.height)) // view to crop the copied imageView below
let image = UIImageView(frame: CGRect(x: targetImageView.frame.minX - self.frame.minX - shrinkImageXPosition,
                                      y: targetImageView.frame.minY - self.frame.minY,
                                      width: targetImageView.frame.width,
                                      height: targetImageView.frame.height)) // imageView that copies the targetImageView
image.translatesAutoresizingMaskIntoConstraints = true
image.image = targetImageView.image
image.contentMode = targetImageView.contentMode
view.translatesAutoresizingMaskIntoConstraints = false
view.clipsToBounds = true
view.addSubview(image)

// 생성한 이미지를 효과 뷰 사이즈로 압축 시킴
let effectedImage = applyGaussianBlur(toImage: makeImage(from: view), radius: 7)
let croppedImageView = UIImageView(image: effectedImage)
croppedImageView.contentMode = .scaleToFill
croppedImageView.frame = CGRect(x: xPosition, y: 0, width: width, height: self.frame.height + 5)

 

 

유리의 너비를 3가지 타입 중에 선택하여 커스터마이징할 수 있습니다.

narrow, default, wide 이렇게 3가지로 구분하였습니다.

이때, 각 타입에 해당하는 너비를 고정값으로 설정하지 않고 '기준값'만 설정했습니다.

그 이유는 효과 뷰의 사이즈를 알 수 없기 때문입니다.

너비를 고정값으로 사용하거나, 몇 등분할지를 정하여 제공한다면, 연동 상황에 따라서 전혀 다른 결과물이 만들어질 것입니다.

그래서 기준값을 설정하고, 이와 근접한 값으로 유리의 너비를 설정하도록 하였습니다.

기준값을 아래와 같습니다.

narrow default wide
20 35 50

 

예를 들어서, default 타입으로 widthType을 설정했다고 가정하겠습니다.

default 타입의 기준값은 35입니다.

효과 뷰의 너비가 300이라고 가정하였을 때, 300 / 35 = 8.571... 로, 온전하게 표시할 수 있는 등분은 총 8 조각입니다.

이 8조각의 총 너비는 35 * 8 = 280이고, 300 - 280 = 20으로 8조각을 제외하면 20이 남게 됩니다.

이 20을 8조각에 2.5씩 분배하도록 하였습니다.

즉, 37.5의 너비의 유리 조각 8개가 들어간 효과가 표시됩니다.

var glassWidth = widthType.rawValue // default일 때, 이 값은 35
let glassCount = round(self.frame.width / glassWidth) // round(300/35) = 8
glassWidth = glassWidth + ((self.frame.width - (glassCount * glassWidth)) / glassCount) // 너비가 37.5

 

 

3. CIFilter

가우시안 블러 효과를 이용하기 위해서 CIFilter, 그중에서도 CIGaussianBlur를 사용했습니다. (참고 문서 링크)

 

블러 적용을 위해 UIVisualEffectView를 이용하지 않은 이유는, 색상 등에 있어서 왜곡이 발생한다고 생각하였기 때문입니다.

UIVisualEffectView를 위에 얹는 방식보다 사진에 직접 효과를 입히는 것이 더 적절하다고 판단하였습니다.

이런 방식의 가우시안 블러를 구현하고자 CIFilter를 이용하였고, 아래와 같이 적용하였습니다.

// 가우시안 블러 효과 적용
let context: CIContext = CIContext()
let currentFilter = CIFilter(name: "CIGaussianBlur")
guard let beginImage = CIImage(image: currentImage) else { return nil }
currentFilter?.setValue(beginImage, forKey: kCIInputImageKey)
currentFilter?.setValue(radius, forKey: kCIInputRadiusKey)
let image = currentFilter?.outputImage

 

이렇게 가우시안 블러 효과를 적용하였을 때, 한 가지 문제가 발생합니다.

위에서 한 번 언급했듯, 일부 테두리에도 블러가 적용되어서 아래와 같이 보이게 됩니다.

검정선은 이미지 뷰의 테두리로, 블러 적용 후 우측에 여백이 생긴 것을 알 수 있음

 

이를 해결하기 위해서 블러된 이미지의 extent에 inset을 추가하여서 테두리를 잘라내고, 이렇게 잘려진 이미지를 갈대 유리 효과 뷰의 frame에 맞게 확장시켰습니다.

private func appleGaussianBlur(toImage currentImage: UIImage, radius : Float) -> UIImage? {
    // 가우시안 블러 효과 적용
    let context: CIContext = CIContext()
    let currentFilter = CIFilter(name: "CIGaussianBlur")
    guard let beginImage = CIImage(image: currentImage) else { return nil }
    currentFilter?.setValue(beginImage, forKey: kCIInputImageKey)
    currentFilter?.setValue(radius, forKey: kCIInputRadiusKey)
    guard let image = currentFilter?.outputImage else { return nil }
    
    // 테두리 크롭 진행
    let newExtent = beginImage.extent.insetBy(dx: 10.5, dy: 10.5) // 테두리 잘라냄. 이후 반환한 image를 효과 뷰 사이즈에 맞게 늘림.
    guard let final = context.createCGImage(image, from: newExtent) else {
        return nil
    }
    return UIImage(cgImage: final)
}

 

 

4. 마치며

완성된 라이브러리는 아래와 같습니다.

 

만족스러운 결과물을 만들어내기 위해서 가장 긴 고민을 한 프로젝트였던 것 같습니다.

다양한 케이스에 대해서 고민해야 했고, 최대한 일관된 느낌의 결과가 나올 수 있도록 설계하려고 노력했습니다.

생각하지 못한 케이스는 없을지 계속 고민하고 업데이트해 나갈 생각입니다! :)