Unity UI: Ripple Effect

2024. 5. 21. 16:25공부

요즘 https://www.youtube.com/@UIMotionEffects/shorts 를 참고해서 UI를 더 맛있게 표현하는 방법을 공부하고 있다.

그 과정에서 애니메이션을 주기 위해 Lerp를 사용하는 법을 많이 배우고 있고 셰이더와 파티클 영역에 발을 들이는 중이다.

 

오늘 만들어 볼것은 Ripple Effect.

UI 버튼을 눌렀을 때 누른 지점에서부터 파동이 퍼져나가는듯한 효과로 만들어 두면 자주 쓸 것같아서 찾아보았다.

검색했을 때 셰이더를 사용해 이미지가 일렁이는 물결효과는 많이 나오지만 UI에서 원형으로 퍼져 나가는 것은 많이 나오지도 않고 에셋스토어에도 무료는 없더라.. 그래서 직접 만들어보기로 결정

 

0. 준비

먼저 버튼에 달아줄 "UIRippleEffect.cs" 스크립트를 만들어주고 필요한 필드를 작성한다.

m_EffectSprite : 클릭시 퍼져나갈 효과의 모양

RippleColor : 클릭시 퍼저나갈 효과의 색깔

GradientColor : 이후 제작할 그라데이션 효과에서 사용할 예정, 여러개의 색상을 담을수 있도록 했다.

MaxPower : Ripple Effect의 처음 Alpha값

Duration : Ripple Effect의 지속시간rippleSize : Ripple Effect의 최대 크기type : 추후 제작할 그라데이션으로 할지 지금처럼 일반적인 효과로 넣을지 선택하는 트리거    - Ripple Effect의 속도를 조절하고 싶다면 rippleSize와 Duration을 적절히 조절하자

 

1. 영역 설정

Ripple Effect가 퍼져나가며 버튼을 넘어가지 않게 하기위해 Mask 효과를 적용한다.m_RectMask : 현재의 게임오브젝트 모양 내에서만 Ripple Effect가 보이도록 영역 설정padding과 softness는 테두리에 적용되는 효과

 

2. 물결효과 애니메이션

먼저 마우스 클릭 or 터치 감지를 위해 상속한 IPointerClickHandler 인터페이스를 구현한다.RippleContainer가 준비되지 않았으면 실행하지 않고 돌아간다.클릭시 생성해줄 GameObject를 선언하고 , 수동으로 위치를 조절할 수 있도록 ignoreLayout을 true로 설정한다.선언 된 rippleObject 게임오브젝트의 sprite와 하이어라키에서의 순서, 위치, 부모transform을 지정하고 생성된 효과가 클릭을 방해하지 않게 하기위해 Image.raycastTarget = false로 설정한다.아래 코루틴을 실행하는 부분은 AnimateRipple() 코루틴 함수를 실행하고 (매개변수 rectTransform, Image, 종료시 실행할 Action 익명함수)로onComplete시(효과 종료시) currentRippleImage를 비워주고 사용된 rippleObject를 Destroy 한 뒤 사용했던 코루틴을 종료한다.

 

이제 버튼에 UIRippleEffect.cs 컴포넌트를 붙여주기만 하면 처음에 봤던것과 같이 클릭시 물결효과가 구현되었다.

 

3. 그라데이션 물결효과

여러 색상을 사용해 그라데이션 느낌이 나는 효과도 추가로 만들어보자필요한 것은 (1)여러 색상, (2)기존 물결효과 반복 으로 먼저 색상을 담을 배열부터 만들어주었다.(Color[] GradientColor)다음으로 코루틴의 WaitForSeconds를 통해 물결효과가 반복되는 사이에 짧은 시간을 기다려 주어야하며각 물결효과는 GradientColor의 색상을 순차적으로 적용한다.

새로운 코루틴 GradientRippleEffect()를 만들어 주고, 각각의 물결효과를 담을 List를 만들어주었다.

처음 만들었던 물결효과 세팅과 동일한 코드로 rippleObject를 만들고 순서대로 List에 담는다.

이후 반복문과 WaitForSeconds를 통해 짧은 시간 간격으로 물결이 여러번 퍼져 나가는 효과를 만들었다.

단 기존에 만든 코루틴으로는 각각의 물결효과의 색상을 변경할수 없었기에 색상을 변경할 수 있도록 기존의 코드에 오버로딩하여 작성했다.

그렇게 만들어진 UI Ripple Effect 컴포넌트

실행하면 Rect Mask 2D가 생성된다.

 

4. 어두운 물결효과

물결 색상이 어두울경우 텍스트를 흰색으로 바꿔주는 기능을 추가했다.

물결효과가 종료되면 초기 색상으로 돌아온다.

어두운 물결효과 적용시 Max Power를 3이나 4정도로 설정해 진한 색상을 쓰는것이 어울리는 듯

 

✔ UIRippleEffect.cs 

더보기
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;

[DisallowMultipleComponent]
public class UIRippleEffect : MonoBehaviour, IPointerClickHandler
{
    [Header("Ripple Setup")]
    public Sprite m_EffectSprite;
    public Color RippleColor;
    public Color[] GradientColor;
    public float MaxPower = 0.25f; // 1
    public float Duration = 0.25f; // 1
    public Vector2 rippleSize = Vector2.one; // 2000, 2000
    public bool type; // gradient, linear
    public Text btnText;
    public Color tempColor;

    private bool m_IsInitialized = false;
    private RectMask2D m_RectMask;

    void Awake()
    {
        if (m_EffectSprite == null)
        {
            Debug.LogWarning("Failed to add ripple graphics. Not Ripple found.");
            return;
        }
        SetupRippleContainer();
    }
    void Start()
    {
        tempColor = btnText.color;
    }
    private void SetupRippleContainer()
    {
        m_RectMask = gameObject.AddComponent<RectMask2D>();
        m_RectMask.padding = new Vector4(0, 0, 0, 0); // 5, 5, 5, 5
        m_RectMask.softness = new Vector2Int(0, 0); // 20, 20
        m_IsInitialized = true;
    }

    public void OnPointerClick(PointerEventData eventData)
    {
        if (!m_IsInitialized) return;
        if (type)
        {
            GameObject rippleObject = new GameObject("_ripple_");
            LayoutElement crl = rippleObject.AddComponent<LayoutElement>();
            crl.ignoreLayout = true;

            Image currentRippleImage = rippleObject.AddComponent<Image>();
            currentRippleImage.sprite = m_EffectSprite;
            currentRippleImage.transform.SetAsLastSibling();
            currentRippleImage.transform.SetPositionAndRotation(eventData.position, Quaternion.identity);
            currentRippleImage.transform.SetParent(transform);
            currentRippleImage.color = new Color(RippleColor.r, RippleColor.g, RippleColor.b, 0f);
            currentRippleImage.raycastTarget = false;

            //hsv color < 30이면 텍스트 하얀색 > 30이면 텍스트 검은색
            float h, s, v;
            Color.RGBToHSV(currentRippleImage.color, out h, out s, out v);
            if (v < 0.3f)
            {
                btnText.color = Color.white;
            }
            else
            {
                btnText.color = Color.black;
            }

            StartCoroutine(AnimateRipple(rippleObject.GetComponent<RectTransform>(), currentRippleImage, () =>
            {
                currentRippleImage = null;
                Destroy(rippleObject);

                if (transform.childCount <= 1) // 텍스트 원래 색으로 돌아오기
                {
                    btnText.color = tempColor;
                }
                StopCoroutine(nameof(AnimateRipple));
            }));
        }
        else
        {
            StartCoroutine(GradientRippleEffect(eventData));
        }
    }
    private IEnumerator GradientRippleEffect(PointerEventData eventData)
    {
        List<GameObject> rippleObjects = new List<GameObject>();

        for (int i = 0; i < 3; i++)
        {
            GameObject rippleObject = new GameObject("_ripple_");
            LayoutElement crl = rippleObject.AddComponent<LayoutElement>();
            crl.ignoreLayout = true;

            Image rippleImage = rippleObject.AddComponent<Image>();
            rippleImage.sprite = m_EffectSprite;
            rippleImage.transform.SetAsLastSibling();
            rippleImage.transform.SetPositionAndRotation(eventData.position, Quaternion.identity);
            rippleImage.transform.SetParent(transform);
            rippleImage.color = new Color(GradientColor[i].r, GradientColor[i].g, GradientColor[i].b, 0f);
            rippleImage.raycastTarget = false;

            rippleObjects.Add(rippleObject);
        }

        int foreachIndex = 0;
        foreach (GameObject rippleObject in rippleObjects)
        {
            Image rippleImage = rippleObject.GetComponent<Image>();
            if (foreachIndex == 0) //0 or rippleObjects.Count-1
            {
                float h, s, v;
                Color.RGBToHSV(rippleImage.color, out h, out s, out v);
                if (v < 0.3f)
                {
                    btnText.color = Color.white;
                }
                else
                {
                    btnText.color = Color.black;
                }
            }

            StartCoroutine(AnimateRipple(rippleObject.GetComponent<RectTransform>(), rippleImage, GradientColor[foreachIndex], () =>
            {
                Destroy(rippleObject);
                if (transform.childCount <= 1) // 텍스트 원래 색으로 돌아오기
                {
                    btnText.color = tempColor;
                }
            }));
            foreachIndex++;
            yield return new WaitForSeconds(0.075f);
        }

    }
    private IEnumerator AnimateRipple(RectTransform rippleTransform, Image rippleImage, Action onComplete)
    {
        Vector2 initialSize = Vector2.zero;
        Vector2 targetSize = rippleSize; // 2000, 2000
        Color initialColor = new Color(RippleColor.r, RippleColor.g, RippleColor.b, MaxPower);
        Color targetColor = new Color(RippleColor.r, RippleColor.g, RippleColor.b, 0f);
        float elapsedTIme = 0f;

        while (elapsedTIme < Duration)
        {
            elapsedTIme += Time.deltaTime;
            rippleTransform.sizeDelta = Vector2.Lerp(initialSize, targetSize, elapsedTIme / Duration);
            rippleImage.color = Color.Lerp(initialColor, targetColor, elapsedTIme / Duration);
            yield return null;

        }
        onComplete?.Invoke();

    }
    private IEnumerator AnimateRipple(RectTransform rippleTransform, Image rippleImage, Color selectColor, Action onComplete)
    { // 그라데이션에서 사용하기 위해 Color를 직접 지정
        Vector2 initialSize = Vector2.zero;
        Vector2 targetSize = rippleSize; // 2000, 2000
        Color initialColor = new Color(selectColor.r, selectColor.g, selectColor.b, MaxPower);
        Color targetColor = new Color(selectColor.r, selectColor.g, selectColor.b, 0f);
        float elapsedTIme = 0f;

        while (elapsedTIme < Duration)
        {
            elapsedTIme += Time.deltaTime;
            rippleTransform.sizeDelta = Vector2.Lerp(initialSize, targetSize, elapsedTIme / Duration);
            rippleImage.color = Color.Lerp(initialColor, targetColor, elapsedTIme / Duration);
            yield return null;

        }
        onComplete?.Invoke();
    }
}