Swift로 인스타그램 스토리 하트 파티클 애니메이션 만들기

Danna 다나·2023년 3월 26일
2
post-thumbnail

인스타그램을 하면서 항상 궁금했다.
내 스토리에 친구들이 하트를 눌러보면 보이는 저 하트가 올라가는 애니메이션은 어떻게 구현한 걸까?

유니티로는 파티클 애니메이션을 구현해 본 적이 있는데, Swift로 네이티브 iOS 앱을 만들 때는 어떻게 만들어야 할지 감도 안 왔다.

이것저것 찾아보니 방법에는 크게 두 가지가 있었다.
CAEmitterLayer와 SpriteKit에 포함되어 있는 SKEmitterNode다.

SKEmitterNode

위에서 잠깐 설명했듯, Swift로 게임을 만들 수 있게 지원하는 SpriteKit에 포함되어 있다.
인스펙터에서 실제 파티클이 생성되는 모습을 보며 값들을 조정해줄 수 있어 편리하다.

하지만 UIView에 올리기 위해서는 SKView라는 SpriteKit의 뷰를 하나 만들어 SpriteKit Scene을 얹고 이를 뷰에 얹어주게 되어 있어 사실상 자연스러운 구조는 아니다.

그렇다고 해서 실제 사용되는 메모리 양을 보니 CAEmitter와 SKEmitter 간 의미있는 수준의 차이는 없었다. 그냥 SKEmitter는 게임을 위해 만들어진 탓에 non-game 앱에서는 구조가 조금 안 예쁠 뿐인 것 같다. (CAEmitter는 일반적인 UIView 위에 레이어 하나만 더해주면 된다.) CAEmitter로 구현이 가능한 경우라면 Non-game에서 굳이 SKEmitter를 썼을 때의 메리트를 찾지 못했다.

그래서 나는 SKEmitterNode로 대략적인 파라미터들의 값과 작동원리를 파악한 후 실제 구현은 CAEmitterLayer로 했다.

이 글에서 SKEmitterNode를 따로 설명하지는 않을 예정이다. 대신 이 영상에 잘 설명되어 있다.

CAEmitterLayer

CAEmitterLayer는 View 위에 얹어지는 Layer의 한 종류로,
CAEmitterCell로 생성되는 파티클들을 Layer 위에 그려내는 방식이다.
CAEmitterLayer와 CAEmitterCell에 설정할 수 있는 프로퍼티가 너무 많아서 일단 코드를 써보며 하나하나 살펴보는 게 훨씬 빠르게 먹힐 것이다.

위로 올라가는 하트 만들기 (방향, 각도 설정)

우선 CAEmitterLayer 하나를 만들어준다.
이게 뭐라고 이해하는 데 많은 시간을 할애하는 것보다는 지금은 그냥 파티클이 뿌려지는 하나의 캔버스라고 생각하면 쉽다.
이 캔버스에서는 파티클이 어떤 모양으로 뿌려질지, 얼마나 많은 파티클이 뿌려질지, 어느 위치에 뿌려질지 등을 정해줄 수 있다.
자세한 프로퍼티는 Zedd님이 잘 정리해두셨다. (Zedd 블로그)

let heartEmitter = CAEmitterLayer()
heartEmitter.emitterPosition = CGPoint(x: view.bounds.width/2, y: view.bounds.height/2)
heartEmitter.emitterSize = CGSize(width: 100, height: 100)
heartEmitter.emitterShape = .circle

이렇게 쓰고 실행시켜봤자 아무 일도 일어나지 않을 것이다.
우리는 지금 캔버스 하나를 만들었을 뿐이고, 실제로 파티클 하나하나가 어떤 특성을 가지는지는 CAEmitterCell에서 정의해주어야 하기 때문이다.
CAEmitterCell에서는 파티클 이미지, 유지 시간, 속도, 나아가는 방향과 각도 등을 정의해줄 수 있다.

아래 코드에서는 예시로 인스타그램처럼 일렁이며 올라가는 하트 셀을 구현해보았다.

let heartCell = CAEmitterCell()
heartCell.contents = UIImage(named: "heart.png")?.cgImage // 이미지
heartCell.birthRate = 5 // 초당 생성 개수
heartCell.lifetime = 1.0 // 셀 유지 시간
heartCell.velocity = 100 // 속도
heartCell.emissionRange = .pi / 5 // 생성 각도
heartCell.emissionLongitude = .pi / -2 // 생성 각도

그 후에 우리가 만든 Layer에 Cell을 얹혀주고,
그 Layer을 원하는 뷰의 subLayer로 더해주면 된다.

heartEmitter.emitterCells = [heartCell]
view.layer.addSublayer(heartEmitter)

여기까지 코드를 쓰면 아래와 같은 모습이 된다. (현재까지의 코드)

위로 올라갈수록 옅어지게 만들기

지금도 충분히 일렁거리며 잘 올라가고 있지만, 어딘가 어색하다.
인스타그램의 하트와는 다르게 올라가며 툭툭 갑자기 사라지는 느낌이 든다.
위로 올라갈수록 alpha 값이 옅어지게 만들어 이를 해결해볼 수 있다.

위로 올라갈수록 alpha 값이 조정되는 건 셀 하나하나의 고유 값이 변경되는 것이므로 Layer가 아닌 Cell의 프로퍼티를 변경해주면 된다.

heartCell.alphaRange = 0.3
heartCell.alphaSpeed = -0.5

alphaRange는 해당 셀이 0부터 얼마까지의 알파 값을 가질 수 있느냐를 의미한다.
여기서는 0부터 0.3 사이의 값을 가지게 했다.

-0.5의 값을 준 alphaSpeed는 시간이 갈수록 알파 값을 줄여줄 것이다.

위로 올라가며 서서히 사라지는 모습이 되었다. (현재까지의 코드)

크기 변화 주기

이제 제법 인스타그램과 비슷해졌는데, 재밌는 걸 좀 더 붙여보려 한다.
셀이 모두 같은 크기엔 게 심심하니 크기에 변화를 줘볼 예정이다.

아래 값들로 변화를 주었다.
시작하는 크기, 크기 변화 범위, 시간이 흐를수록 얼마나 크기를 빠르게 변화시킬 건지 등을 설정해줄 수 있다.

heartCell.scale = 0.15
heartCell.scaleRange = 1
heartCell.scaleSpeed = 0.5

크기가 좀 더 다양한 하트가 되었다.

이모지를 EmitterCell로 사용할 수 있을까?

이쯤 되면 궁금한 게 생긴다.
이미지가 아닌 텍스트도 EmitterCell로 사용할 수 있을까?
텍스트도 사용할 수 있다면, 이미지를 하나하나 찾지 않고 기본 이모지를 활용해 더 재미난 파티클 애니메이션을 만들어볼 수 있을 것이다.

당연히 가능하다.

heartCell.contents = {
    let emoji = "🎉"
    let font = UIFont.systemFont(ofSize: 10)
    let size = emoji.size(withAttributes: [.font: font])
    let renderer = UIGraphicsImageRenderer(size: size)
    let image = renderer.image { context in
        emoji.draw(at: .zero, withAttributes: [.font: font])
    }
    return image.cgImage
}()

다만, 텍스트 자체로 contents 프로퍼티에 넣어주니 이모지는 나오지 않았고,
텍스트 이모지를 이미지로 렌더링 한 후 cgImage로 변환시켜주는 과정이 필요했다.

이모지도 잘 나오는 모습이다. (더 많은 파티클이 나오게끔 파라미터를 조금 조정해주었다) (현재까지의 코드)

이모지 섞기

여기까지 오니 욕심이 나기 시작한다.
꼭 한 종류의 이모지만 나와야 할까? 여러 종류의 이모지가 한 번에 섞여 나오면 더 멋있지 않을까?

물론 이것도 가능하다.

하나의 CAEmitterLayer는 여러 개의 CAEmitterCell을 가질 수 있다.
이 점을 활용하면 된다.

신경 써야 할 것이 많지는 않다.

  1. 이모지 개수만큼 Cell이 생성되게 할 것
  2. 이모지 개수가 많아져도 실제 스폰 되는 개수는 변하지 않게 할 것
let emojiStrings = ["❤️", "💙", "💛", "💜", "🤎"]

var emitterCells: [CAEmitterCell] = [] // 셀들을 담을 곳을 만들고
for emoji in emojiStrings { // 각 이모지에 대해 셀 생성
    let emitterCell = CAEmitterCell()
    emitterCell.contents = {
        let font = UIFont.systemFont(ofSize: 20)
        let size = emoji.size(withAttributes: [.font: font])
        let renderer = UIGraphicsImageRenderer(size: size)
        let image = renderer.image { context in
            emoji.draw(at: .zero, withAttributes: [.font: font])
        }
        return image.cgImage
    }()
    emitterCell.birthRate = 30 / Float(emojiStrings.count) // 생성 개수는 이모지 개수로 나눠준다
    // (다른 프로퍼티들은 생략)
    
    emitterCells.append(emitterCell)
}

emitterLayer.emitterCells = emitterCells

여기까지 쓰면 이런 다채로운 하트 이모지들을 만날 수 있다. (현재까지의 코드)

정리하며

여기선 인스타 스토리 하트 파티클을 만든다는 목표로 하나의 형태의 파티클을 만들어봤지만, 실제로 CAEmitter를 잘 활용하면 눈/비 내리는 효과, 폭발 효과, 반딧불 등 예쁜 이펙트를 많이 만들어낼 수 있다.

이것저것 만져보면 재밌기도 하고, 생각보다 러닝커브가 높지 않다는 걸 느낄 수 있을 것이다.

아래는 전체 코드다.

//
//  InstaStyleParticleVC.swift
//  iOSLab
//
//  Created by Danna Lee on 2023/03/26.
//

import UIKit

class InstaStyleParticleVC: UIViewController {

    override func viewDidLoad() {
        super.viewDidLoad()

        generateParticle()
    }
}

extension InstaStyleParticleVC {
    private func generateParticle() {
        let emitterLayer = CAEmitterLayer()
        emitterLayer.emitterPosition = CGPoint(x: view.bounds.width/2, y: view.bounds.height - 100)
        emitterLayer.emitterSize = CGSize(width: 100, height: 100)
        emitterLayer.emitterShape = .point
        
        let emojiStrings = ["🥳", "👏🏻", "🍾", "🎉"]

        var emitterCells: [CAEmitterCell] = []
        for emoji in emojiStrings {
            let emitterCell = CAEmitterCell()
            emitterCell.contents = {
                let font = UIFont.systemFont(ofSize: 20)
                let size = emoji.size(withAttributes: [.font: font])
                let renderer = UIGraphicsImageRenderer(size: size)
                let image = renderer.image { context in
                    emoji.draw(at: .zero, withAttributes: [.font: font])
                }
                return image.cgImage
            }()
            emitterCell.birthRate = 30 / Float(emojiStrings.count)
            emitterCell.lifetime = 1.0
            emitterCell.velocity = 300
            emitterCell.velocityRange = 50
            emitterCell.emissionRange = .pi / 5
            emitterCell.emissionLongitude = .pi / -2
            emitterCell.alphaRange = 0.3
            emitterCell.alphaSpeed = -0.5
            emitterCell.scale = 0.15
            emitterCell.scaleRange = 1
            emitterCell.scaleSpeed = 0.5
            
            emitterCells.append(emitterCell)
        }
        
        emitterLayer.emitterCells = emitterCells
        view.layer.addSublayer(emitterLayer)
    }
}

신나게 자축하며 끝

profile
요즘은 https://welcometodannas.tistory.com/에 더 많은 글을 씁니다.

1개의 댓글

comment-user-thumbnail
2023년 3월 27일

재밌는 시도네요! SpriteKit 저도 나중에 사용해봐야겠어요 ㅎㅎ

답글 달기